From 3506328441ae739d07744beda3fee450a89b16bd Mon Sep 17 00:00:00 2001 From: nianzhibai Date: Sun, 10 May 2026 23:55:04 +0800 Subject: [PATCH] Add PikPak drive support Add PikPak backend driver, fixed tag matching, cached transcode playback, fast cover handling, and LF normalization. --- .gitattributes | 11 + .gitignore | 43 +- README.md | 223 ++-- backend/README.md | 268 ++--- backend/cmd/server/main.go | 902 +++++++------- backend/config.example.yaml | 83 +- backend/internal/api/admin.go | 573 ++++----- backend/internal/api/admin_test.go | 118 ++ backend/internal/api/api.go | 820 ++++++++----- backend/internal/api/api_test.go | 145 +++ backend/internal/auth/auth.go | 162 +-- backend/internal/catalog/catalog.go | 1013 ++++++++-------- backend/internal/catalog/schema.sql | 144 +-- backend/internal/config/config.go | 234 ++-- backend/internal/drives/iface.go | 129 +- backend/internal/drives/p115/driver.go | 434 +++---- backend/internal/drives/pikpak/auth.go | 283 +++++ backend/internal/drives/pikpak/driver.go | 333 ++++++ backend/internal/drives/pikpak/driver_test.go | 103 ++ backend/internal/drives/pikpak/types.go | 87 ++ backend/internal/drives/quark/driver.go | 690 +++++------ backend/internal/drives/wopan/driver.go | 462 ++++---- backend/internal/fixedtags/tags.go | 100 ++ backend/internal/fixedtags/tags_test.go | 33 + backend/internal/preview/ffmpeg.go | 1055 +++++++++-------- backend/internal/preview/worker_test.go | 159 +++ backend/internal/proxy/proxy.go | 338 +++--- backend/internal/scanner/filename.go | 91 +- backend/internal/scanner/filename_test.go | 32 + backend/internal/scanner/scanner.go | 302 ++--- backend/internal/scanner/scanner_test.go | 184 +++ index.html | 28 +- package.json | 52 +- public/favicon.svg | 2 +- src/App.tsx | 118 +- src/admin/AdminLayout.tsx | 130 +- src/admin/AuthContext.tsx | 130 +- src/admin/DrivesPage.tsx | 839 +++++++------ src/admin/LoginPage.tsx | 182 +-- src/admin/Modal.tsx | 76 +- src/admin/PreviewToggle.tsx | 140 +-- src/admin/RequireAuth.tsx | 72 +- src/admin/ToastContext.tsx | 114 +- src/admin/VideosPage.tsx | 608 +++++----- src/admin/api.ts | 340 +++--- src/components/AppShell.tsx | 46 +- src/components/BackToTop.tsx | 50 +- src/components/CommentPanel.tsx | 94 +- src/components/Footer.tsx | 36 +- src/components/MainNav.tsx | 130 +- src/components/Pagination.tsx | 134 +-- src/components/PreviewVideo.tsx | 82 +- src/components/PromoStrip.tsx | 48 +- src/components/RecommendedRail.tsx | 30 +- src/components/SearchPanel.tsx | 118 +- src/components/SectionHeader.tsx | 30 +- src/components/SortToolbar.tsx | 124 +- src/components/SubNav.tsx | 40 +- src/components/TagCloud.tsx | 84 +- src/components/TopBar.tsx | 52 +- src/components/VideoActions.tsx | 236 ++-- src/components/VideoCard.tsx | 400 ++++--- src/components/VideoGrid.tsx | 80 +- src/components/VideoInfoPanel.tsx | 260 ++-- src/components/VideoPlayer.tsx | 107 +- src/data/categories.ts | 8 +- src/data/tags.ts | 10 +- src/data/videos.ts | 84 +- src/lib/format.ts | 14 +- src/lib/previewController.ts | 52 +- src/lib/useInViewport.ts | 104 +- src/main.tsx | 54 +- src/pages/HomePage.tsx | 104 +- src/pages/ListingPage.tsx | 198 ++-- src/pages/VideoDetailPage.tsx | 190 +-- src/styles/admin.css | 1022 ++++++++-------- src/styles/base.css | 168 +-- src/styles/layout.css | 302 ++--- src/styles/navigation.css | 298 ++--- src/styles/search.css | 272 ++--- src/styles/tokens.css | 66 +- src/styles/video-card.css | 662 +++++------ src/styles/video-detail.css | 678 +++++------ src/types.ts | 160 +-- tsconfig.json | 50 +- video-site-implementation-plan.md | 5 +- vite.config.ts | 41 +- 87 files changed, 10644 insertions(+), 8464 deletions(-) create mode 100644 .gitattributes create mode 100644 backend/internal/api/admin_test.go create mode 100644 backend/internal/api/api_test.go create mode 100644 backend/internal/drives/pikpak/auth.go create mode 100644 backend/internal/drives/pikpak/driver.go create mode 100644 backend/internal/drives/pikpak/driver_test.go create mode 100644 backend/internal/drives/pikpak/types.go create mode 100644 backend/internal/fixedtags/tags.go create mode 100644 backend/internal/fixedtags/tags_test.go create mode 100644 backend/internal/preview/worker_test.go create mode 100644 backend/internal/scanner/filename_test.go create mode 100644 backend/internal/scanner/scanner_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..640dbdf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.db binary +*.mp4 binary +*.sqlite binary diff --git a/.gitignore b/.gitignore index 2045cfd..118e358 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,22 @@ -node_modules -dist -.vite -*.log -.DS_Store -.vscode/ -.idea/ - -# 第三方源码参考,可选,详见 vendor-refs/README.md -vendor-refs/ - -# 后端数据目录(SQLite + teaser/封面) -backend/data/ -backend/config.yaml - -# 工具链 -tools/ - -# 注意:backend/vendor/ 是 Go modules vendor 目录,故意入库 -# 目的是让任何人 clone 后断网也能直接 go build -# 不要加 `vendor/` 或 `backend/vendor/` 到这里 +node_modules +dist +.vite +*.log +.DS_Store +.vscode/ +.idea/ + +# 第三方源码参考,可选,详见 vendor-refs/README.md +vendor-refs/ +OpenList-4.2.1/ + +# 后端数据目录(SQLite + teaser/封面) +backend/data/ +backend/config.yaml + +# 工具链 +tools/ + +# 注意:backend/vendor/ 是 Go modules vendor 目录,故意入库 +# 目的是让任何人 clone 后断网也能直接 go build +# 不要加 `vendor/` 或 `backend/vendor/` 到这里 diff --git a/README.md b/README.md index 9ce0032..0f65946 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,112 @@ -# 视频聚合站 - -把夸克 / 115 / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。 - -- 前端:React 18 + Vite + TypeScript -- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面 -- 三家网盘接入:夸克自研 + 115driver SDK + wopan-sdk-go SDK - -## 快速开始 - -### 环境要求 - -- Node.js 18+ 和 npm -- Go 1.23+ -- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面) - -Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin` 和 `\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。 - -### 运行 - -```bash -# 前端 -npm install -npm run dev # 监听 http://127.0.0.1:5173 - -# 后端(另开终端) -cd backend -go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy -``` - -首次启动后端会自动生成: - -- `backend/config.yaml`(从 `config.example.yaml` 复制) -- `backend/data/video-site.db`(SQLite) -- `backend/data/previews/`(teaser 和封面本地目录) - -Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `:8080`。浏览器访问 `http://127.0.0.1:5173/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `config.yaml` 里改)。 - -## 目录 - -``` -. -├─ src/ React 前端 -├─ backend/ Go 后端(单体服务) -│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建 -├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略 -│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考 -├─ video-site-implementation-plan.md 完整的设计和实现记录 -└─ README.md -``` - -### 依赖管理 - -所有 Go 依赖都已通过 `go mod vendor` 打包进 `backend/vendor/` 并入库。别人 clone 仓库后,**无需联网**,直接 `go run ./cmd/server` 就能编译运行。 - -升级依赖的流程: - -```bash -cd backend -go get github.com/SheltonZhu/115driver@<新版本> -go mod tidy -go mod vendor # 把新依赖同步到 vendor 目录 -git add vendor/ # 入库 -``` - -### `vendor-refs/` 要不要在意? - -不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译。 - -## 加一个网盘 - -1. 登录 `/admin` → 网盘管理 → 新建 -2. 选类型(夸克 / 115 / 沃盘),填名称 + 凭证 -3. 保存后会自动触发一次扫描 -4. 在 `/admin/videos` 里看扫到了多少视频 -5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser - -三家盘的凭证字段: - -| 类型 | 凭证字段 | 获取方式 | -|---|---|---| -| 夸克 | `cookie` | pan.quark.cn 登录后 F12 拷 Cookie | -| 115 | `cookie` | 115.com 登录后拷 Cookie(`UID=...; CID=...; SEID=...; KID=...`) | -| 沃盘 | `access_token`、`refresh_token`、可选 `family_id` | 第一版只能手动粘贴 token;后续会加扫码/短信登录 | - -## Teaser 和封面生成策略 - -- 封面:根据视频时长从 20% 或 30% 位置抽一帧 jpg -- Teaser:3 段拼接(`20% / 50% / 80%` 位置各 3 秒,带 0.2s fade-in/out),总长约 9 秒,目标体积 500 KB - 1.5 MB -- 短视频 (< 30s) 自动降级为单段 -- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重生 -- 详见 plan 15.12 节 - -## 部署到 Linux - -```bash -# 本机交叉编译 -cd backend -GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server - -# 目标服务器 -sudo apt install ffmpeg -scp video-server user@host:/opt/video-site/ -# 配 systemd + nginx 反代到 /、/api、/p、/admin -``` - -完整部署方式见 plan 15.10 节。 - -## 贡献 - -任何代码改动请保持和 `video-site-implementation-plan.md` 同步;重要的设计决策追加到第 14 节(实现备注)或第 15 节(后端)。 +# 视频聚合站 + +把夸克 / 115 / PikPak / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。 + +- 前端:React 18 + Vite + TypeScript +- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面 +- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList)+ wopan-sdk-go SDK + +## 快速开始 + +### 环境要求 + +- Node.js 18+ 和 npm +- Go 1.23+ +- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面) + +Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin` 和 `\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。 + +### 运行 + +```bash +# 前端 +npm install +npm run dev # 监听 http://127.0.0.1:5173 + +# 后端(另开终端) +cd backend +go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy +``` + +首次启动后端会自动生成: + +- `backend/config.yaml`(从 `config.example.yaml` 复制) +- `backend/data/video-site.db`(SQLite) +- `backend/data/previews/`(teaser 和封面本地目录) + +Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `:8080`。浏览器访问 `http://127.0.0.1:5173/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `config.yaml` 里改)。 + +## 目录 + +``` +. +├─ src/ React 前端 +├─ backend/ Go 后端(单体服务) +│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建 +├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略 +│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考 +├─ video-site-implementation-plan.md 完整的设计和实现记录 +└─ README.md +``` + +### 依赖管理 + +所有 Go 依赖都已通过 `go mod vendor` 打包进 `backend/vendor/` 并入库。别人 clone 仓库后,**无需联网**,直接 `go run ./cmd/server` 就能编译运行。 + +升级依赖的流程: + +```bash +cd backend +go get github.com/SheltonZhu/115driver@<新版本> +go mod tidy +go mod vendor # 把新依赖同步到 vendor 目录 +git add vendor/ # 入库 +``` + +### `vendor-refs/` 要不要在意? + +不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译。 + +## 加一个网盘 + +1. 登录 `/admin` → 网盘管理 → 新建 +2. 选类型(夸克 / 115 / PikPak / 沃盘),填名称 + 凭证 +3. 保存后会自动触发一次扫描 +4. 在 `/admin/videos` 里看扫到了多少视频 +5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser + +各网盘的凭证字段: + +| 类型 | 凭证字段 | 获取方式 | +|---|---|---| +| 夸克 | `cookie` | pan.quark.cn 登录后 F12 拷 Cookie | +| 115 | `cookie` | 115.com 登录后拷 Cookie(`UID=...; CID=...; SEID=...; KID=...`) | +| PikPak | `username`、`password`,可选 `refresh_token`、`captcha_token`、`device_id`、`platform`、`disable_media_link` | 参考 OpenList PikPak driver;首次登录成功会自动回写 token | +| 沃盘 | `access_token`、`refresh_token`、可选 `family_id` | 第一版只能手动粘贴 token;后续会加扫码/短信登录 | + +## Teaser 和封面生成策略 + +- 封面:根据视频时长从 20% 或 30% 位置抽一帧 jpg +- Teaser:3 段拼接(`20% / 50% / 80%` 位置各 3 秒,带 0.2s fade-in/out),总长约 9 秒,目标体积 500 KB - 1.5 MB +- 短视频 (< 30s) 自动降级为单段 +- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重生 +- 详见 plan 15.12 节 + +## 部署到 Linux + +```bash +# 本机交叉编译 +cd backend +GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server + +# 目标服务器 +sudo apt install ffmpeg +scp video-server user@host:/opt/video-site/ +# 配 systemd + nginx 反代到 /、/api、/p、/admin +``` + +完整部署方式见 plan 15.10 节。 + +## 贡献 + +任何代码改动请保持和 `video-site-implementation-plan.md` 同步;重要的设计决策追加到第 14 节(实现备注)或第 15 节(后端)。 diff --git a/backend/README.md b/backend/README.md index bc0eb0b..d161d66 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,133 +1,135 @@ -# backend - -视频聚合站的 Go 后端。提供三件事: - -1. 三家网盘统一抽象(夸克 / 115 / 联通沃盘) -2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成 -3. REST API(前台)+ 管理后台 + 直链代理 - -## 目录 - -``` -cmd/server/main.go 入口 -internal/ - config/ YAML 配置 - catalog/ SQLite 元数据 - drives/ - iface.go Drive 接口 - quark/ 夸克(自己实现,参考 OpenList quark_uc) - p115/ 115(壳子 + SheltonZhu/115driver) - wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go) - scanner/ 扫目录 → 落库 - preview/ ffmpeg 抽 10s teaser - proxy/ /p/stream/*、/p/preview/* 代理 - auth/ 管理员 session - api/ REST 路由 -config.example.yaml 配置模板 -``` - -## 开发环境(Windows) - -本仓库假设工具都装在用户目录,不需要管理员权限。 - -``` -C:\Users\\tools\ - go\bin\go.exe Go 1.23+ - ffmpeg\bin\ffmpeg.exe 任意 ≥ 4.x 版本 -``` - -并加到 `PATH`。 - -### 第一次启动 - -```powershell -cd F:\VideoProject\backend -go mod tidy -go run ./cmd/server -``` - -首次启动会在当前目录创建: - -- `config.yaml`(从 `config.example.yaml` 复制) -- `data/video-site.db` -- `data/previews/` - -默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。 - -### 连接前端 - -`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。 - -``` -npm run dev 前端 5173 -go run ./cmd/server 后端 8080 -``` - -## 添加一个盘 - -1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}` -2. 新建盘:`POST /admin/api/drives` - ```json - { - "id": "my-quark", - "kind": "quark", - "name": "我的夸克盘", - "rootId": "0", - "scanRootId": "0", - "credentials": { - "cookie": "粘贴浏览器 F12 复制的 pan.quark.cn Cookie" - } - } - ``` -3. 手动触发扫描:`POST /admin/api/drives/my-quark/rescan` - -三家盘的凭证字段: - -| kind | credentials 字段 | -|--------|---------------------------------------------------------------| -| quark | `cookie` | -| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) | -| wopan | `access_token`、`refresh_token`,可选 `family_id` | - -## 文件名约定 - -扫描器按以下顺序解析文件名: - -1. `[tag1,tag2] 标题 - 作者.mp4` -2. `[tag1,tag2] 标题.mp4` -3. `标题 - 作者.mp4` -4. `标题.mp4` - -标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。 - -## Teaser 生成 - -scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用: - -``` -ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \ - -t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \ - -movflags +faststart -y .mp4 -``` - -优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/.mp4` 作为兜底。 - -前端卡片的 `previewSrc` 统一指向 `/p/preview/`,后端自动选择网盘代理或本地文件。 - -## 部署到 Linux - -```bash -# 交叉编译 -GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server - -# 目标机 -sudo apt install ffmpeg -scp video-server user@host:/opt/video-site/ -ssh user@host -cd /opt/video-site -cp config.example.yaml config.yaml -# 改密码、监听地址 -./video-server -``` - -配 systemd + nginx 反代到 `/` 和 `/api`、`/p`、`/admin`。 +# backend + +视频聚合站的 Go 后端。提供三件事: + +1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘) +2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成 +3. REST API(前台)+ 管理后台 + 直链代理 + +## 目录 + +``` +cmd/server/main.go 入口 +internal/ + config/ YAML 配置 + catalog/ SQLite 元数据 + drives/ + iface.go Drive 接口 + quark/ 夸克(自己实现,参考 OpenList quark_uc) + p115/ 115(壳子 + SheltonZhu/115driver) + pikpak/ PikPak(自己实现,参考 OpenList pikpak) + wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go) + scanner/ 扫目录 → 落库 + preview/ ffmpeg 抽 10s teaser + proxy/ /p/stream/*、/p/preview/* 代理 + auth/ 管理员 session + api/ REST 路由 +config.example.yaml 配置模板 +``` + +## 开发环境(Windows) + +本仓库假设工具都装在用户目录,不需要管理员权限。 + +``` +C:\Users\\tools\ + go\bin\go.exe Go 1.23+ + ffmpeg\bin\ffmpeg.exe 任意 ≥ 4.x 版本 +``` + +并加到 `PATH`。 + +### 第一次启动 + +```powershell +cd F:\VideoProject\backend +go mod tidy +go run ./cmd/server +``` + +首次启动会在当前目录创建: + +- `config.yaml`(从 `config.example.yaml` 复制) +- `data/video-site.db` +- `data/previews/` + +默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。 + +### 连接前端 + +`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。 + +``` +npm run dev 前端 5173 +go run ./cmd/server 后端 8080 +``` + +## 添加一个盘 + +1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}` +2. 新建盘:`POST /admin/api/drives` + ```json + { + "id": "my-quark", + "kind": "quark", + "name": "我的夸克盘", + "rootId": "0", + "scanRootId": "0", + "credentials": { + "cookie": "粘贴浏览器 F12 复制的 pan.quark.cn Cookie" + } + } + ``` +3. 手动触发扫描:`POST /admin/api/drives/my-quark/rescan` + +各网盘的凭证字段: + +| kind | credentials 字段 | +|--------|---------------------------------------------------------------| +| quark | `cookie` | +| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) | +| pikpak | `username`、`password`,可选 `refresh_token`、`captcha_token`、`device_id`、`platform`、`disable_media_link` | +| wopan | `access_token`、`refresh_token`,可选 `family_id` | + +## 文件名约定 + +扫描器按以下顺序解析文件名: + +1. `[tag1,tag2] 标题 - 作者.mp4` +2. `[tag1,tag2] 标题.mp4` +3. `标题 - 作者.mp4` +4. `标题.mp4` + +标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。 + +## Teaser 生成 + +scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用: + +``` +ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \ + -t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \ + -movflags +faststart -y .mp4 +``` + +优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/.mp4` 作为兜底。 + +前端卡片的 `previewSrc` 统一指向 `/p/preview/`,后端自动选择网盘代理或本地文件。 + +## 部署到 Linux + +```bash +# 交叉编译 +GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server + +# 目标机 +sudo apt install ffmpeg +scp video-server user@host:/opt/video-site/ +ssh user@host +cd /opt/video-site +cp config.example.yaml config.yaml +# 改密码、监听地址 +./video-server +``` + +配 systemd + nginx 反代到 `/` 和 `/api`、`/p`、`/admin`。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 244dbf5..612fade 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,425 +1,477 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "path/filepath" - "sync" - "syscall" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - - "github.com/video-site/backend/internal/api" - "github.com/video-site/backend/internal/auth" - "github.com/video-site/backend/internal/catalog" - "github.com/video-site/backend/internal/config" - "github.com/video-site/backend/internal/drives" - "github.com/video-site/backend/internal/drives/p115" - "github.com/video-site/backend/internal/drives/quark" - "github.com/video-site/backend/internal/drives/wopan" - "github.com/video-site/backend/internal/preview" - "github.com/video-site/backend/internal/proxy" - "github.com/video-site/backend/internal/scanner" -) - -func main() { - cfgPath := "./config.yaml" - if v := os.Getenv("VIDEO_CONFIG"); v != "" { - cfgPath = v - } - cfg, err := config.Load(cfgPath) - if err != nil { - log.Fatalf("load config: %v", err) - } - - if err := os.MkdirAll(filepath.Dir(cfg.Storage.DBPath), 0o755); err != nil { - log.Fatalf("mkdir db dir: %v", err) - } - if err := os.MkdirAll(cfg.Storage.LocalPreviewDir, 0o755); err != nil { - log.Fatalf("mkdir preview dir: %v", err) - } - - cat, err := catalog.Open(cfg.Storage.DBPath) - if err != nil { - log.Fatalf("open catalog: %v", err) - } - defer cat.Close() - - app := &App{ - cfg: cfg, - cat: cat, - registry: proxy.NewRegistry(), - workers: make(map[string]*preview.Worker), - } - app.proxy = proxy.New(app.registry) - - // 初始化现有 drives - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - app.loadPreviewEnabled(ctx) - - existing, err := cat.ListDrives(ctx) - if err != nil { - log.Fatalf("list drives: %v", err) - } - for _, d := range existing { - if err := app.attachDrive(ctx, d); err != nil { - log.Printf("[drive %s] attach failed: %v", d.ID, err) - } - } - - authr := &auth.Authenticator{ - Username: cfg.Server.Admin.Username, - Password: cfg.Server.Admin.Password, - Catalog: cat, - } - - apiServer := &api.Server{ - Catalog: cat, - Proxy: app.proxy, - LocalDir: cfg.Storage.LocalPreviewDir, - } - - adminServer := &api.AdminServer{ - Catalog: cat, - Auth: authr, - OnDriveSaved: func(driveID string) error { - d, err := cat.GetDrive(ctx, driveID) - if err != nil { - return err - } - return app.attachDrive(ctx, d) - }, - OnDriveRemoved: func(driveID string) { - app.detachDrive(driveID) - }, - OnScanRequested: func(driveID string) { - go app.runScan(ctx, driveID) - }, - OnRegenPreview: func(videoID string) { - go app.regenPreview(ctx, videoID) - }, - GetPreviewEnabled: func() bool { return app.PreviewEnabled() }, - SetPreviewEnabled: func(enabled bool) error { - return app.SetPreviewEnabled(ctx, enabled) - }, - } - - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(corsMiddleware) - - apiServer.RegisterRoutes(r, authr) - adminServer.Register(r) - - // 启动定时扫描 - go app.scanLoop(ctx) - - srv := &http.Server{ - Addr: cfg.Server.Listen, - Handler: r, - } - go func() { - log.Printf("video-site backend listening on %s", cfg.Server.Listen) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("server error: %v", err) - } - }() - - // 等待退出信号 - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - <-sigs - log.Println("shutting down...") - shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutCancel() - _ = srv.Shutdown(shutCtx) -} - -// ---------- App ---------- - -type App struct { - cfg *config.Config - cat *catalog.Catalog - registry *proxy.Registry - proxy *proxy.Proxy - - mu sync.Mutex - workers map[string]*preview.Worker - cancels map[string]context.CancelFunc - - // 运行时 preview 开关(从 DB 读) - previewEnabled bool -} - -// PreviewEnabled 线程安全读 -func (a *App) PreviewEnabled() bool { - a.mu.Lock() - defer a.mu.Unlock() - return a.previewEnabled -} - -// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending -func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error { - a.mu.Lock() - a.previewEnabled = enabled - a.mu.Unlock() - - val := "0" - if enabled { - val = "1" - } - if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil { - return err - } - - if enabled { - // 异步补扫所有盘 - go func() { - for _, d := range a.registry.All() { - a.mu.Lock() - w := a.workers[d.ID()] - a.mu.Unlock() - if w != nil { - a.enqueuePending(ctx, d.ID(), w) - } - } - }() - } - return nil -} - -// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值 -func (a *App) loadPreviewEnabled(ctx context.Context) { - def := "0" - if a.cfg.Preview.Enabled { - def = "1" - } - v, err := a.cat.GetSetting(ctx, "preview.enabled", def) - if err != nil { - log.Printf("[preview] load setting: %v (fallback to config)", err) - a.mu.Lock() - a.previewEnabled = a.cfg.Preview.Enabled - a.mu.Unlock() - return - } - a.mu.Lock() - a.previewEnabled = v == "1" - a.mu.Unlock() -} - -func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error { - var drv drives.Drive - switch d.Kind { - case "quark": - drv = quark.New(quark.Config{ - ID: d.ID, - Cookie: d.Credentials["cookie"], - RootID: d.RootID, - OnCookieUpdate: func(cookie string) { - d.Credentials["cookie"] = cookie - _ = a.cat.UpsertDrive(ctx, d) - }, - }) - case "p115": - drv = p115.New(p115.Config{ - ID: d.ID, - Cookie: d.Credentials["cookie"], - RootID: d.RootID, - }) - case "wopan": - drv = wopan.New(wopan.Config{ - ID: d.ID, - AccessToken: d.Credentials["access_token"], - RefreshToken: d.Credentials["refresh_token"], - FamilyID: d.Credentials["family_id"], - RootID: d.RootID, - OnTokenUpdate: func(access, refresh string) { - d.Credentials["access_token"] = access - d.Credentials["refresh_token"] = refresh - _ = a.cat.UpsertDrive(ctx, d) - }, - }) - default: - return fmt.Errorf("unknown drive kind: %s", d.Kind) - } - - if err := drv.Init(ctx); err != nil { - d.Status = "error" - d.LastError = err.Error() - _ = a.cat.UpsertDrive(ctx, d) - return err - } - - d.Status = "ok" - d.LastError = "" - _ = a.cat.UpsertDrive(ctx, d) - - a.registry.Set(d.ID, drv) - - // preview worker - gen := preview.New(preview.Config{ - FFmpegPath: a.cfg.Preview.FFmpegPath, - FFprobePath: a.cfg.Preview.FFprobePath, - DurationSeconds: a.cfg.Preview.DurationSeconds, - Width: a.cfg.Preview.Width, - Segments: a.cfg.Preview.Segments, - LocalDir: a.cfg.Storage.LocalPreviewDir, - RemoteDir: a.cfg.Preview.RemoteDir, - }) - worker := preview.NewWorker(gen, a.cat, drv, a.cfg.Preview.RemoteDir) - - workerCtx, cancel := context.WithCancel(ctx) - go worker.Run(workerCtx) - - a.mu.Lock() - if a.cancels == nil { - a.cancels = make(map[string]context.CancelFunc) - } - if old, ok := a.cancels[d.ID]; ok { - old() - } - a.workers[d.ID] = worker - a.cancels[d.ID] = cancel - a.mu.Unlock() - - // 启动补扫:把这个盘下所有 pending 的视频塞进 worker 队列 - // 使用 goroutine 因为队列可能比预期的小,Enqueue 直接丢弃,调用方也无需等待 - if a.PreviewEnabled() { - go a.enqueuePending(workerCtx, d.ID, worker) - } - return nil -} - -func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) { - pending, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "pending", 0) - if err != nil { - log.Printf("[preview] list pending %s: %v", driveID, err) - return - } - if len(pending) == 0 { - return - } - log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID) - for _, v := range pending { - w.Enqueue(v) - } -} - -func (a *App) detachDrive(id string) { - a.registry.Remove(id) - a.mu.Lock() - if cancel, ok := a.cancels[id]; ok { - cancel() - delete(a.cancels, id) - } - delete(a.workers, id) - a.mu.Unlock() -} - -func (a *App) runScan(ctx context.Context, driveID string) { - drv, ok := a.registry.Get(driveID) - if !ok { - log.Printf("[scan] drive %s not attached", driveID) - return - } - - a.mu.Lock() - worker := a.workers[driveID] - a.mu.Unlock() - - var onNew func(v *catalog.Video) - if a.PreviewEnabled() && worker != nil { - onNew = worker.Enqueue - } - - sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew) - - // 使用 drive 的 scan_root_id,否则 root_id - d, err := a.cat.GetDrive(ctx, driveID) - if err != nil { - log.Printf("[scan] get drive %s: %v", driveID, err) - return - } - startID := d.ScanRootID - if startID == "" { - startID = d.RootID - } - - log.Printf("[scan] drive=%s start=%s", driveID, startID) - stats, err := sc.Run(ctx, startID) - if err != nil { - log.Printf("[scan] drive=%s error: %v", driveID, err) - return - } - log.Printf("[scan] drive=%s done scanned=%d added=%d", driveID, stats.Scanned, stats.Added) -} - -func (a *App) regenPreview(ctx context.Context, videoID string) { - v, err := a.cat.GetVideo(ctx, videoID) - if err != nil { - return - } - a.mu.Lock() - worker := a.workers[v.DriveID] - a.mu.Unlock() - if worker != nil { - worker.Enqueue(v) - } -} - -func (a *App) scanLoop(ctx context.Context) { - // 启动后立刻扫一次 - a.scanAllOnce(ctx) - - if a.cfg.Scanner.IntervalSeconds <= 0 { - return - } - ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - a.scanAllOnce(ctx) - } - } -} - -func (a *App) scanAllOnce(ctx context.Context) { - for _, d := range a.registry.All() { - a.runScan(ctx, d.ID()) - } -} - -// ---------- middleware ---------- - -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", originOr(r, "*")) - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func originOr(r *http.Request, fallback string) string { - if o := r.Header.Get("Origin"); o != "" { - return o - } - return fallback -} +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/video-site/backend/internal/api" + "github.com/video-site/backend/internal/auth" + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/config" + "github.com/video-site/backend/internal/drives" + "github.com/video-site/backend/internal/drives/p115" + "github.com/video-site/backend/internal/drives/pikpak" + "github.com/video-site/backend/internal/drives/quark" + "github.com/video-site/backend/internal/drives/wopan" + "github.com/video-site/backend/internal/preview" + "github.com/video-site/backend/internal/proxy" + "github.com/video-site/backend/internal/scanner" +) + +func main() { + cfgPath := "./config.yaml" + if v := os.Getenv("VIDEO_CONFIG"); v != "" { + cfgPath = v + } + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(cfg.Storage.DBPath), 0o755); err != nil { + log.Fatalf("mkdir db dir: %v", err) + } + if err := os.MkdirAll(cfg.Storage.LocalPreviewDir, 0o755); err != nil { + log.Fatalf("mkdir preview dir: %v", err) + } + + cat, err := catalog.Open(cfg.Storage.DBPath) + if err != nil { + log.Fatalf("open catalog: %v", err) + } + defer cat.Close() + + app := &App{ + cfg: cfg, + cat: cat, + registry: proxy.NewRegistry(), + workers: make(map[string]*preview.Worker), + thumbWorkers: make(map[string]*preview.ThumbWorker), + } + app.proxy = proxy.New(app.registry) + + // 初始化现有 drives + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + app.loadPreviewEnabled(ctx) + + existing, err := cat.ListDrives(ctx) + if err != nil { + log.Fatalf("list drives: %v", err) + } + for _, d := range existing { + if err := app.attachDrive(ctx, d); err != nil { + log.Printf("[drive %s] attach failed: %v", d.ID, err) + } + } + + authr := &auth.Authenticator{ + Username: cfg.Server.Admin.Username, + Password: cfg.Server.Admin.Password, + Catalog: cat, + } + + apiServer := &api.Server{ + Catalog: cat, + Proxy: app.proxy, + LocalDir: cfg.Storage.LocalPreviewDir, + FFmpegPath: cfg.Preview.FFmpegPath, + } + + adminServer := &api.AdminServer{ + Catalog: cat, + Auth: authr, + OnDriveSaved: func(driveID string) error { + d, err := cat.GetDrive(ctx, driveID) + if err != nil { + return err + } + return app.attachDrive(ctx, d) + }, + OnDriveRemoved: func(driveID string) { + app.detachDrive(driveID) + }, + OnScanRequested: func(driveID string) { + go app.runScan(ctx, driveID) + }, + OnRegenPreview: func(videoID string) { + go app.regenPreview(ctx, videoID) + }, + GetPreviewEnabled: func() bool { return app.PreviewEnabled() }, + SetPreviewEnabled: func(enabled bool) error { + return app.SetPreviewEnabled(ctx, enabled) + }, + } + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(corsMiddleware) + + apiServer.RegisterRoutes(r, authr) + adminServer.Register(r) + + // 启动定时扫描 + go app.scanLoop(ctx) + + srv := &http.Server{ + Addr: cfg.Server.Listen, + Handler: r, + } + go func() { + log.Printf("video-site backend listening on %s", cfg.Server.Listen) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + // 等待退出信号 + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + <-sigs + log.Println("shutting down...") + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + _ = srv.Shutdown(shutCtx) +} + +// ---------- App ---------- + +type App struct { + cfg *config.Config + cat *catalog.Catalog + registry *proxy.Registry + proxy *proxy.Proxy + + mu sync.Mutex + workers map[string]*preview.Worker + thumbWorkers map[string]*preview.ThumbWorker + cancels map[string]context.CancelFunc + + // 运行时 preview 开关(从 DB 读) + previewEnabled bool +} + +// PreviewEnabled 线程安全读 +func (a *App) PreviewEnabled() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.previewEnabled +} + +// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending +func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error { + a.mu.Lock() + a.previewEnabled = enabled + a.mu.Unlock() + + val := "0" + if enabled { + val = "1" + } + if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil { + return err + } + + if enabled { + // 异步补扫所有盘 + go func() { + for _, d := range a.registry.All() { + a.mu.Lock() + w := a.workers[d.ID()] + a.mu.Unlock() + if w != nil { + a.enqueuePending(ctx, d.ID(), w) + } + } + }() + } + return nil +} + +// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值 +func (a *App) loadPreviewEnabled(ctx context.Context) { + def := "0" + if a.cfg.Preview.Enabled { + def = "1" + } + v, err := a.cat.GetSetting(ctx, "preview.enabled", def) + if err != nil { + log.Printf("[preview] load setting: %v (fallback to config)", err) + a.mu.Lock() + a.previewEnabled = a.cfg.Preview.Enabled + a.mu.Unlock() + return + } + a.mu.Lock() + a.previewEnabled = v == "1" + a.mu.Unlock() +} + +func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error { + var drv drives.Drive + switch d.Kind { + case "quark": + drv = quark.New(quark.Config{ + ID: d.ID, + Cookie: d.Credentials["cookie"], + RootID: d.RootID, + OnCookieUpdate: func(cookie string) { + d.Credentials["cookie"] = cookie + _ = a.cat.UpsertDrive(ctx, d) + }, + }) + case "p115": + drv = p115.New(p115.Config{ + ID: d.ID, + Cookie: d.Credentials["cookie"], + RootID: d.RootID, + }) + case "pikpak": + drv = pikpak.New(pikpak.Config{ + ID: d.ID, + Username: d.Credentials["username"], + Password: d.Credentials["password"], + Platform: d.Credentials["platform"], + RefreshToken: d.Credentials["refresh_token"], + AccessToken: d.Credentials["access_token"], + CaptchaToken: d.Credentials["captcha_token"], + DeviceID: d.Credentials["device_id"], + RootID: d.RootID, + DisableMediaLink: pikpak.ParseBoolDefault(d.Credentials["disable_media_link"], true), + OnTokenUpdate: func(access, refresh, captcha, deviceID string) { + d.Credentials["access_token"] = access + d.Credentials["refresh_token"] = refresh + d.Credentials["captcha_token"] = captcha + d.Credentials["device_id"] = deviceID + _ = a.cat.UpsertDrive(ctx, d) + }, + }) + case "wopan": + drv = wopan.New(wopan.Config{ + ID: d.ID, + AccessToken: d.Credentials["access_token"], + RefreshToken: d.Credentials["refresh_token"], + FamilyID: d.Credentials["family_id"], + RootID: d.RootID, + OnTokenUpdate: func(access, refresh string) { + d.Credentials["access_token"] = access + d.Credentials["refresh_token"] = refresh + _ = a.cat.UpsertDrive(ctx, d) + }, + }) + default: + return fmt.Errorf("unknown drive kind: %s", d.Kind) + } + + if err := drv.Init(ctx); err != nil { + d.Status = "error" + d.LastError = err.Error() + _ = a.cat.UpsertDrive(ctx, d) + return err + } + + d.Status = "ok" + d.LastError = "" + _ = a.cat.UpsertDrive(ctx, d) + + a.registry.Set(d.ID, drv) + + // preview worker + gen := preview.New(preview.Config{ + FFmpegPath: a.cfg.Preview.FFmpegPath, + FFprobePath: a.cfg.Preview.FFprobePath, + DurationSeconds: a.cfg.Preview.DurationSeconds, + Width: a.cfg.Preview.Width, + Segments: a.cfg.Preview.Segments, + LocalDir: a.cfg.Storage.LocalPreviewDir, + RemoteDir: a.cfg.Preview.RemoteDir, + }) + worker := preview.NewWorker(gen, a.cat, drv, a.cfg.Preview.RemoteDir) + thumbWorker := preview.NewThumbWorker(gen, a.cat, drv) + + workerCtx, cancel := context.WithCancel(ctx) + go worker.Run(workerCtx) + go thumbWorker.Run(workerCtx) + + a.mu.Lock() + if a.cancels == nil { + a.cancels = make(map[string]context.CancelFunc) + } + if old, ok := a.cancels[d.ID]; ok { + old() + } + a.workers[d.ID] = worker + a.thumbWorkers[d.ID] = thumbWorker + a.cancels[d.ID] = cancel + a.mu.Unlock() + + return nil +} + +func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) { + pending, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "pending", 0) + if err != nil { + log.Printf("[preview] list pending %s: %v", driveID, err) + return + } + if len(pending) == 0 { + return + } + log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID) + for _, v := range pending { + w.Enqueue(v) + } +} + +func (a *App) enqueueThumbnails(ctx context.Context, driveID string, w *preview.ThumbWorker) { + pending, err := a.cat.ListVideosNeedingThumbnail(ctx, driveID, 0) + if err != nil { + log.Printf("[thumb] list pending %s: %v", driveID, err) + return + } + if len(pending) == 0 { + return + } + log.Printf("[thumb] enqueue %d missing thumbnails for drive=%s", len(pending), driveID) + for _, v := range pending { + w.Enqueue(v) + } +} + +func (a *App) detachDrive(id string) { + a.registry.Remove(id) + a.mu.Lock() + if cancel, ok := a.cancels[id]; ok { + cancel() + delete(a.cancels, id) + } + delete(a.workers, id) + delete(a.thumbWorkers, id) + a.mu.Unlock() +} + +func (a *App) runScan(ctx context.Context, driveID string) { + drv, ok := a.registry.Get(driveID) + if !ok { + log.Printf("[scan] drive %s not attached", driveID) + return + } + + a.mu.Lock() + worker := a.workers[driveID] + thumbWorker := a.thumbWorkers[driveID] + a.mu.Unlock() + + var onNew func(v *catalog.Video) + if thumbWorker != nil || (a.PreviewEnabled() && worker != nil) { + onNew = func(v *catalog.Video) { + if thumbWorker != nil && v.ThumbnailURL == "" { + thumbWorker.Enqueue(v) + } + if a.PreviewEnabled() && worker != nil { + worker.Enqueue(v) + } + } + } + + sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew) + + // 使用 drive 的 scan_root_id,否则 root_id + d, err := a.cat.GetDrive(ctx, driveID) + if err != nil { + log.Printf("[scan] get drive %s: %v", driveID, err) + return + } + startID := d.ScanRootID + if startID == "" { + startID = d.RootID + } + + log.Printf("[scan] drive=%s start=%s", driveID, startID) + stats, err := sc.Run(ctx, startID) + if err != nil { + log.Printf("[scan] drive=%s error: %v", driveID, err) + return + } + log.Printf("[scan] drive=%s done scanned=%d added=%d", driveID, stats.Scanned, stats.Added) + if thumbWorker != nil { + a.enqueueThumbnails(ctx, driveID, thumbWorker) + } + if a.PreviewEnabled() && worker != nil { + go a.enqueuePending(ctx, driveID, worker) + } +} + +func (a *App) regenPreview(ctx context.Context, videoID string) { + v, err := a.cat.GetVideo(ctx, videoID) + if err != nil { + return + } + a.mu.Lock() + worker := a.workers[v.DriveID] + a.mu.Unlock() + if worker != nil { + worker.Enqueue(v) + } +} + +func (a *App) scanLoop(ctx context.Context) { + // 启动后立刻扫一次 + a.scanAllOnce(ctx) + + if a.cfg.Scanner.IntervalSeconds <= 0 { + return + } + ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.scanAllOnce(ctx) + } + } +} + +func (a *App) scanAllOnce(ctx context.Context) { + for _, d := range a.registry.All() { + a.runScan(ctx, d.ID()) + } +} + +// ---------- middleware ---------- + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", originOr(r, "*")) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func originOr(r *http.Request, fallback string) string { + if o := r.Header.Get("Origin"); o != "" { + return o + } + return fallback +} diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 2aa28d2..e3b9cb7 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -1,41 +1,42 @@ -# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。 -server: - listen: ":8080" - # 管理后台用户,生产环境请务必修改 - admin: - username: "admin" - password: "admin123" - # 用于签发 admin session cookie,生产请改成随机字符串 - session_secret: "change-me-to-a-random-string" - -storage: - # SQLite 数据库文件路径 - db_path: "./data/video-site.db" - # 本地 teaser 兜底目录(网盘写入失败时使用) - local_preview_dir: "./data/previews" - -scanner: - # 扫描间隔(秒),0 表示只启动时扫一次 - interval_seconds: 21600 - # 单次扫描每家网盘目录递归层数上限 - max_depth: 5 - # 被扫描的扩展名 - video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"] - -preview: - # 是否启用 ffmpeg 抽帧生成 teaser - enabled: true - # ffmpeg / ffprobe 可执行文件名或绝对路径 - ffmpeg_path: "ffmpeg" - ffprobe_path: "ffprobe" - # teaser 总时长(秒)。多段模式下会均分给每段 - duration_seconds: 9 - # teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接 - segments: 3 - # teaser 视频宽度 - width: 480 - # teaser 上传到网盘的目录(相对网盘根)。空值则写本地 - remote_dir: "/previews" - -# 盘列表。上线后请通过管理后台添加,本文件可留空 -drives: [] +# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。 +server: + listen: ":8080" + # 管理后台用户,生产环境请务必修改 + admin: + username: "admin" + password: "admin123" + # 用于签发 admin session cookie,生产请改成随机字符串 + session_secret: "change-me-to-a-random-string" + +storage: + # SQLite 数据库文件路径 + db_path: "./data/video-site.db" + # 本地 teaser 兜底目录(网盘写入失败时使用) + local_preview_dir: "./data/previews" + +scanner: + # 扫描间隔(秒),0 表示只启动时扫一次 + interval_seconds: 21600 + # 单次扫描每家网盘目录递归层数上限 + max_depth: 5 + # 被扫描的扩展名 + video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"] + +preview: + # 是否启用 ffmpeg 抽帧生成 teaser + enabled: true + # ffmpeg / ffprobe 可执行文件名或绝对路径 + ffmpeg_path: "ffmpeg" + ffprobe_path: "ffprobe" + # teaser 总时长(秒)。多段模式下会均分给每段 + duration_seconds: 9 + # teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接 + segments: 3 + # teaser 视频宽度 + width: 480 + # teaser 上传到网盘的目录(相对网盘根)。空值则写本地 + remote_dir: "/previews" + +# 盘列表。上线后请通过管理后台添加,本文件可留空。 +# kind 支持 quark / p115 / pikpak / wopan。 +drives: [] diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 892b7e6..7437972 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -1,284 +1,289 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/video-site/backend/internal/auth" - "github.com/video-site/backend/internal/catalog" -) - -type AdminServer struct { - Catalog *catalog.Catalog - Auth *auth.Authenticator - // Hooks:外层注入实际执行者 - OnDriveSaved func(driveID string) error - OnDriveRemoved func(driveID string) - OnScanRequested func(driveID string) - OnRegenPreview func(videoID string) - // Preview 开关读写 - GetPreviewEnabled func() bool - SetPreviewEnabled func(enabled bool) error -} - -func (a *AdminServer) Register(r chi.Router) { - r.Route("/admin/api", func(r chi.Router) { - // 登录、登出不需要鉴权 - r.Post("/login", a.handleLogin) - r.Post("/logout", a.handleLogout) - r.Get("/me", a.handleMe) - - // 其余路由需鉴权 - r.Group(func(r chi.Router) { - r.Use(a.Auth.Required) - - // 网盘 - r.Get("/drives", a.handleListDrives) - r.Post("/drives", a.handleUpsertDrive) - r.Delete("/drives/{id}", a.handleDeleteDrive) - r.Post("/drives/{id}/rescan", a.handleRescan) - - // 视频 - r.Get("/videos", a.handleAdminListVideos) - r.Put("/videos/{id}", a.handleUpdateVideo) - r.Post("/videos/{id}/regen-preview", a.handleRegenPreview) - - // 运行时设置 - r.Get("/settings", a.handleGetSettings) - r.Put("/settings", a.handlePutSettings) - }) - }) -} - -type loginReq struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { - var body loginReq - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, err) - return - } - ok, err := a.Auth.Login(w, r, body.Username, body.Password) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - if !ok { - http.Error(w, "invalid credentials", http.StatusUnauthorized) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (a *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) { - a.Auth.Logout(w, r) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie("vs_admin") - if err != nil { - writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) - return - } - ok, _ := a.Catalog.ValidateSession(r.Context(), c.Value) - writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok}) -} - -func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { - drives, err := a.Catalog.ListDrives(r.Context()) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - // 出参不返回凭证明文,只告诉前端是否已配置 - type out struct { - ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - RootID string `json:"rootId"` - ScanRootID string `json:"scanRootId"` - Status string `json:"status"` - LastError string `json:"lastError,omitempty"` - HasCredential bool `json:"hasCredential"` - } - list := make([]out, 0, len(drives)) - for _, d := range drives { - list = append(list, out{ - ID: d.ID, Kind: d.Kind, Name: d.Name, - RootID: d.RootID, ScanRootID: d.ScanRootID, - Status: d.Status, LastError: d.LastError, - HasCredential: len(d.Credentials) > 0, - }) - } - writeJSON(w, http.StatusOK, list) -} - -type upsertDriveReq struct { - ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - RootID string `json:"rootId"` - ScanRootID string `json:"scanRootId"` - Credentials map[string]string `json:"credentials"` -} - -func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) { - var body upsertDriveReq - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, err) - return - } - if body.ID == "" || body.Kind == "" { - http.Error(w, "id and kind are required", http.StatusBadRequest) - return - } - d := &catalog.Drive{ - ID: body.ID, Kind: body.Kind, Name: body.Name, - RootID: body.RootID, ScanRootID: body.ScanRootID, - Credentials: body.Credentials, - Status: "disconnected", - } - if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - if a.OnDriveSaved != nil { - if err := a.OnDriveSaved(body.ID); err != nil { - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "warning": err.Error()}) - return - } - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (a *AdminServer) handleDeleteDrive(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := a.Catalog.DeleteDrive(r.Context(), id); err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - if a.OnDriveRemoved != nil { - a.OnDriveRemoved(id) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if a.OnScanRequested != nil { - a.OnScanRequested(id) - } - writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) -} - -func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) { - items, total, err := a.Catalog.ListVideos(r.Context(), catalog.ListParams{ - Page: 1, PageSize: 100, - }) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total}) -} - -type updateVideoReq struct { - Title string `json:"title"` - Author string `json:"author"` - Tags []string `json:"tags"` - Category string `json:"category"` - Badges []string `json:"badges"` - Description string `json:"description"` - Thumbnail string `json:"thumbnail"` - Quality string `json:"quality"` - DurationSec int `json:"durationSeconds"` -} - -func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var body updateVideoReq - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, err) - return - } - v, err := a.Catalog.GetVideo(r.Context(), id) - if err != nil { - writeErr(w, http.StatusNotFound, err) - return - } - if body.Title != "" { - v.Title = body.Title - } - if body.Author != "" { - v.Author = body.Author - } - if body.Tags != nil { - v.Tags = body.Tags - } - if body.Category != "" { - v.Category = body.Category - } - if body.Badges != nil { - v.Badges = body.Badges - } - if body.Description != "" { - v.Description = body.Description - } - if body.Thumbnail != "" { - v.ThumbnailURL = body.Thumbnail - } - if body.Quality != "" { - v.Quality = body.Quality - } - if body.DurationSec > 0 { - v.DurationSeconds = body.DurationSec - } - if err := a.Catalog.UpsertVideo(r.Context(), v); err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, v) -} - -func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if a.OnRegenPreview != nil { - a.OnRegenPreview(id) - } - writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) -} - -// ---------- Settings ---------- - -type settingsDTO struct { - PreviewEnabled bool `json:"previewEnabled"` -} - -func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) { - enabled := false - if a.GetPreviewEnabled != nil { - enabled = a.GetPreviewEnabled() - } - writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled}) -} - -func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) { - var body settingsDTO - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, err) - return - } - if a.SetPreviewEnabled != nil { - if err := a.SetPreviewEnabled(body.PreviewEnabled); err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - } - writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled}) -} +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/video-site/backend/internal/auth" + "github.com/video-site/backend/internal/catalog" +) + +type AdminServer struct { + Catalog *catalog.Catalog + Auth *auth.Authenticator + // Hooks:外层注入实际执行者 + OnDriveSaved func(driveID string) error + OnDriveRemoved func(driveID string) + OnScanRequested func(driveID string) + OnRegenPreview func(videoID string) + // Preview 开关读写 + GetPreviewEnabled func() bool + SetPreviewEnabled func(enabled bool) error +} + +func (a *AdminServer) Register(r chi.Router) { + r.Route("/admin/api", func(r chi.Router) { + // 登录、登出不需要鉴权 + r.Post("/login", a.handleLogin) + r.Post("/logout", a.handleLogout) + r.Get("/me", a.handleMe) + + // 其余路由需鉴权 + r.Group(func(r chi.Router) { + r.Use(a.Auth.Required) + + // 网盘 + r.Get("/drives", a.handleListDrives) + r.Post("/drives", a.handleUpsertDrive) + r.Delete("/drives/{id}", a.handleDeleteDrive) + r.Post("/drives/{id}/rescan", a.handleRescan) + + // 视频 + r.Get("/videos", a.handleAdminListVideos) + r.Put("/videos/{id}", a.handleUpdateVideo) + r.Post("/videos/{id}/regen-preview", a.handleRegenPreview) + + // 运行时设置 + r.Get("/settings", a.handleGetSettings) + r.Put("/settings", a.handlePutSettings) + }) + }) +} + +type loginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { + var body loginReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + ok, err := a.Auth.Login(w, r, body.Username, body.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + if !ok { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) { + a.Auth.Logout(w, r) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie("vs_admin") + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) + return + } + ok, _ := a.Catalog.ValidateSession(r.Context(), c.Value) + writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok}) +} + +func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { + drives, err := a.Catalog.ListDrives(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + // 出参不返回凭证明文,只告诉前端是否已配置 + type out struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + RootID string `json:"rootId"` + ScanRootID string `json:"scanRootId"` + Status string `json:"status"` + LastError string `json:"lastError,omitempty"` + HasCredential bool `json:"hasCredential"` + } + list := make([]out, 0, len(drives)) + for _, d := range drives { + list = append(list, out{ + ID: d.ID, Kind: d.Kind, Name: d.Name, + RootID: d.RootID, ScanRootID: d.ScanRootID, + Status: d.Status, LastError: d.LastError, + HasCredential: len(d.Credentials) > 0, + }) + } + writeJSON(w, http.StatusOK, list) +} + +type upsertDriveReq struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + RootID string `json:"rootId"` + ScanRootID string `json:"scanRootId"` + Credentials map[string]string `json:"credentials"` +} + +func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) { + var body upsertDriveReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + if body.ID == "" || body.Kind == "" { + http.Error(w, "id and kind are required", http.StatusBadRequest) + return + } + if len(body.Credentials) == 0 { + if existing, err := a.Catalog.GetDrive(r.Context(), body.ID); err == nil && len(existing.Credentials) > 0 { + body.Credentials = existing.Credentials + } + } + d := &catalog.Drive{ + ID: body.ID, Kind: body.Kind, Name: body.Name, + RootID: body.RootID, ScanRootID: body.ScanRootID, + Credentials: body.Credentials, + Status: "disconnected", + } + if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + if a.OnDriveSaved != nil { + if err := a.OnDriveSaved(body.ID); err != nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "warning": err.Error()}) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *AdminServer) handleDeleteDrive(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := a.Catalog.DeleteDrive(r.Context(), id); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + if a.OnDriveRemoved != nil { + a.OnDriveRemoved(id) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if a.OnScanRequested != nil { + a.OnScanRequested(id) + } + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) +} + +func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) { + items, total, err := a.Catalog.ListVideos(r.Context(), catalog.ListParams{ + Page: 1, PageSize: 100, + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total}) +} + +type updateVideoReq struct { + Title string `json:"title"` + Author string `json:"author"` + Tags []string `json:"tags"` + Category string `json:"category"` + Badges []string `json:"badges"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` + Quality string `json:"quality"` + DurationSec int `json:"durationSeconds"` +} + +func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var body updateVideoReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + v, err := a.Catalog.GetVideo(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, err) + return + } + if body.Title != "" { + v.Title = body.Title + } + if body.Author != "" { + v.Author = body.Author + } + if body.Tags != nil { + v.Tags = body.Tags + } + if body.Category != "" { + v.Category = body.Category + } + if body.Badges != nil { + v.Badges = body.Badges + } + if body.Description != "" { + v.Description = body.Description + } + if body.Thumbnail != "" { + v.ThumbnailURL = body.Thumbnail + } + if body.Quality != "" { + v.Quality = body.Quality + } + if body.DurationSec > 0 { + v.DurationSeconds = body.DurationSec + } + if err := a.Catalog.UpsertVideo(r.Context(), v); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, v) +} + +func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if a.OnRegenPreview != nil { + a.OnRegenPreview(id) + } + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) +} + +// ---------- Settings ---------- + +type settingsDTO struct { + PreviewEnabled bool `json:"previewEnabled"` +} + +func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) { + enabled := false + if a.GetPreviewEnabled != nil { + enabled = a.GetPreviewEnabled() + } + writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled}) +} + +func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) { + var body settingsDTO + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + if a.SetPreviewEnabled != nil { + if err := a.SetPreviewEnabled(body.PreviewEnabled); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + } + writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled}) +} diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go new file mode 100644 index 0000000..e25a632 --- /dev/null +++ b/backend/internal/api/admin_test.go @@ -0,0 +1,118 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/video-site/backend/internal/catalog" +) + +func TestHandleUpsertDrivePreservesExistingCredentialsWhenRequestCredentialsEmpty(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) + } + }) + + if err := cat.UpsertDrive(ctx, &catalog.Drive{ + ID: "quark-main", + Kind: "quark", + Name: "Old name", + RootID: "0", + ScanRootID: "0", + Credentials: map[string]string{ + "cookie": "existing-cookie", + }, + Status: "ok", + }); err != nil { + t.Fatalf("seed drive: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", strings.NewReader(`{ + "id": "quark-main", + "kind": "quark", + "name": "New name", + "rootId": "0", + "scanRootId": "scan-root", + "credentials": {} + }`)) + rr := httptest.NewRecorder() + + (&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + got, err := cat.GetDrive(ctx, "quark-main") + if err != nil { + t.Fatalf("get drive: %v", err) + } + if got.Name != "New name" { + t.Fatalf("name = %q, want New name", got.Name) + } + if got.ScanRootID != "scan-root" { + t.Fatalf("scanRootId = %q, want scan-root", got.ScanRootID) + } + if got.Credentials["cookie"] != "existing-cookie" { + t.Fatalf("cookie credential = %q, want existing-cookie", got.Credentials["cookie"]) + } +} + +func TestHandleUpsertDriveReplacesExistingCredentialsWhenProvided(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) + } + }) + + if err := cat.UpsertDrive(ctx, &catalog.Drive{ + ID: "quark-main", + Kind: "quark", + Name: "Old name", + RootID: "0", + ScanRootID: "0", + Credentials: map[string]string{ + "cookie": "existing-cookie", + }, + Status: "ok", + }); err != nil { + t.Fatalf("seed drive: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", bytes.NewBufferString(`{ + "id": "quark-main", + "kind": "quark", + "name": "New name", + "rootId": "0", + "scanRootId": "0", + "credentials": {"cookie": "new-cookie"} + }`)) + rr := httptest.NewRecorder() + + (&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + got, err := cat.GetDrive(ctx, "quark-main") + if err != nil { + t.Fatalf("get drive: %v", err) + } + if got.Credentials["cookie"] != "new-cookie" { + t.Fatalf("cookie credential = %q, want new-cookie", got.Credentials["cookie"]) + } +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index c9616e5..3fec77b 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -1,305 +1,515 @@ -package api - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - - "github.com/video-site/backend/internal/auth" - "github.com/video-site/backend/internal/catalog" - "github.com/video-site/backend/internal/proxy" -) - -type Server struct { - Catalog *catalog.Catalog - Proxy *proxy.Proxy - LocalDir string -} - -// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐 -type VideoDTO struct { - ID string `json:"id"` - Href string `json:"href"` - Title string `json:"title"` - Thumbnail string `json:"thumbnail"` - PreviewSrc string `json:"previewSrc"` - PreviewDuration int `json:"previewDuration"` - PreviewStrategy string `json:"previewStrategy"` - Duration string `json:"duration"` - Badges []string `json:"badges"` - Quality string `json:"quality,omitempty"` - Author string `json:"author"` - Views int `json:"views"` - Favorites int `json:"favorites"` - Comments int `json:"comments"` - Likes int `json:"likes"` - Dislikes int `json:"dislikes"` - PublishedAt string `json:"publishedAt"` - Tags []string `json:"tags,omitempty"` - Category string `json:"category,omitempty"` -} - -type VideoDetailDTO struct { - VideoDTO - VideoSrc string `json:"videoSrc"` - Poster string `json:"poster"` - Description string `json:"description"` - EmbedURL string `json:"embedUrl"` - Points int `json:"points,omitempty"` - AuthorProfile AuthorProfile `json:"authorProfile"` - RelatedVideos []VideoDTO `json:"relatedVideos"` - CommentsList []Comment `json:"commentsList"` -} - -type AuthorProfile struct { - ID string `json:"id"` - Name string `json:"name"` - Href string `json:"href"` - Badges []string `json:"badges"` -} - -type Comment struct { - ID string `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` - Likes int `json:"likes,omitempty"` -} - -// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。 -func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) { - r.Group(func(r chi.Router) { - r.Use(a.Required) - r.Get("/api/home", s.handleHome) - r.Get("/api/list", s.handleList) - r.Get("/api/video/{id}", s.handleVideoDetail) - r.Post("/api/video/{id}/like", s.handleLike) - r.Get("/api/tags", s.handleTags) - - // 代理路由同样需要鉴权,防止绕过 - r.Get("/p/stream/{driveID}/{fileID}", s.handleStream) - r.Get("/p/preview/{videoID}", s.handlePreview) - r.Get("/p/thumb/{videoID}", s.handleThumb) - }) -} - -func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { - items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ - Sort: "hot", Page: 1, PageSize: 24, - }) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, mapVideos(items)) -} - -func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - page, _ := strconv.Atoi(q.Get("page")) - size, _ := strconv.Atoi(q.Get("size")) - if size <= 0 { - size = 24 - } - params := catalog.ListParams{ - Keyword: q.Get("q"), - Tag: q.Get("tag"), - Category: q.Get("cat"), - Sort: q.Get("sort"), - Page: page, - PageSize: size, - } - items, total, err := s.Catalog.ListVideos(r.Context(), params) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "items": mapVideos(items), - "total": total, - "page": params.Page, - "size": params.PageSize, - }) -} - -func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - v, err := s.Catalog.GetVideo(r.Context(), id) - if err != nil { - writeErr(w, http.StatusNotFound, err) - return - } - related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ - Sort: "hot", Page: 1, PageSize: 8, - }) - - detail := VideoDetailDTO{ - VideoDTO: mapVideo(v), - VideoSrc: fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID), - Poster: v.ThumbnailURL, - Description: v.Description, - EmbedURL: fmt.Sprintf(``, v.ID), - AuthorProfile: AuthorProfile{ - ID: "author-" + v.Author, - Name: v.Author, - Href: "/author/" + v.Author, - Badges: []string{}, - }, - RelatedVideos: filterVideos(mapVideos(related), v.ID), - CommentsList: []Comment{}, - } - writeJSON(w, http.StatusOK, detail) -} - -func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) { - cats, err := s.Catalog.ListCategories(r.Context()) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - type tag struct { - ID string `json:"id"` - Label string `json:"label"` - Count int `json:"count"` - } - out := make([]tag, 0, len(cats)) - for _, c := range cats { - out = append(out, tag{ID: c.Category, Label: c.Category, Count: c.Count}) - } - writeJSON(w, http.StatusOK, out) -} - -func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - likes, err := s.Catalog.IncrementLike(r.Context(), id) - if err != nil { - writeErr(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"likes": likes}) -} - -func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { - driveID := chi.URLParam(r, "driveID") - fileID := chi.URLParam(r, "fileID") - s.Proxy.ServeStream(w, r, driveID, fileID) -} - -func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { - videoID := chi.URLParam(r, "videoID") - v, err := s.Catalog.GetVideo(r.Context(), videoID) - if err != nil { - http.NotFound(w, r) - return - } - if v.PreviewStatus != "ready" { - http.Error(w, "preview not ready", http.StatusNotFound) - return - } - if v.PreviewFileID != "" { - s.Proxy.ServeStream(w, r, v.DriveID, v.PreviewFileID) - return - } - if v.PreviewLocal != "" { - if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) { - http.Error(w, "invalid local path", http.StatusForbidden) - return - } - s.Proxy.ServeLocal(w, r, v.PreviewLocal) - return - } - http.NotFound(w, r) -} - -func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { - videoID := chi.URLParam(r, "videoID") - // 直接读本地 thumbs 目录中 .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 _, err := os.Stat(clean); err != nil { - http.NotFound(w, r) - return - } - s.Proxy.ServeLocal(w, r, clean) -} - -// ---------- helpers ---------- - -func mapVideo(v *catalog.Video) VideoDTO { - badges := v.Badges - if badges == nil { - badges = []string{} - } - tags := v.Tags - if tags == nil { - tags = []string{} - } - return VideoDTO{ - ID: v.ID, - Href: "/video/" + v.ID, - Title: v.Title, - Thumbnail: v.ThumbnailURL, - PreviewSrc: "/p/preview/" + v.ID, - PreviewDuration: 10, - PreviewStrategy: "teaser-file", - Duration: formatDuration(v.DurationSeconds), - Badges: badges, - Quality: v.Quality, - Author: v.Author, - Views: v.Views, - Favorites: v.Favorites, - Comments: v.Comments, - Likes: v.Likes, - Dislikes: v.Dislikes, - PublishedAt: v.PublishedAt.Format("2006-01-02"), - Tags: tags, - Category: v.Category, - } -} - -func mapVideos(vs []*catalog.Video) []VideoDTO { - out := make([]VideoDTO, 0, len(vs)) - for _, v := range vs { - out = append(out, mapVideo(v)) - } - return out -} - -func filterVideos(vs []VideoDTO, exclude string) []VideoDTO { - out := make([]VideoDTO, 0, len(vs)) - for _, v := range vs { - if v.ID != exclude { - out = append(out, v) - } - } - return out -} - -func formatDuration(sec int) string { - if sec <= 0 { - return "00:00" - } - m := sec / 60 - s := sec % 60 - return fmt.Sprintf("%02d:%02d", m, s) -} - -func writeJSON(w http.ResponseWriter, code int, body any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(body) -} - -func writeErr(w http.ResponseWriter, code int, err error) { - writeJSON(w, code, map[string]string{"error": err.Error()}) -} +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/go-chi/chi/v5" + + "github.com/video-site/backend/internal/auth" + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/fixedtags" + "github.com/video-site/backend/internal/proxy" +) + +type Server struct { + Catalog *catalog.Catalog + Proxy *proxy.Proxy + LocalDir string + FFmpegPath string + + transcodeMu sync.Mutex + transcodeJobs map[string]bool +} + +// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐 +type VideoDTO struct { + ID string `json:"id"` + Href string `json:"href"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + PreviewSrc string `json:"previewSrc"` + PreviewDuration int `json:"previewDuration"` + PreviewStrategy string `json:"previewStrategy"` + Duration string `json:"duration"` + Badges []string `json:"badges"` + Quality string `json:"quality,omitempty"` + Author string `json:"author"` + Views int `json:"views"` + Favorites int `json:"favorites"` + Comments int `json:"comments"` + Likes int `json:"likes"` + Dislikes int `json:"dislikes"` + PublishedAt string `json:"publishedAt"` + Tags []string `json:"tags,omitempty"` + Category string `json:"category,omitempty"` +} + +type VideoDetailDTO struct { + VideoDTO + VideoSrc string `json:"videoSrc"` + Poster string `json:"poster"` + Description string `json:"description"` + EmbedURL string `json:"embedUrl"` + Points int `json:"points,omitempty"` + AuthorProfile AuthorProfile `json:"authorProfile"` + RelatedVideos []VideoDTO `json:"relatedVideos"` + CommentsList []Comment `json:"commentsList"` +} + +type AuthorProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Href string `json:"href"` + Badges []string `json:"badges"` +} + +type Comment struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + Likes int `json:"likes,omitempty"` +} + +// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。 +func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) { + r.Group(func(r chi.Router) { + r.Use(a.Required) + r.Get("/api/home", s.handleHome) + r.Get("/api/list", s.handleList) + r.Get("/api/video/{id}", s.handleVideoDetail) + r.Post("/api/video/{id}/like", s.handleLike) + r.Get("/api/tags", s.handleTags) + + // 代理路由同样需要鉴权,防止绕过 + r.Get("/p/stream/{driveID}/{fileID}", s.handleStream) + r.Get("/p/transcode/{videoID}/status", s.handleTranscodeStatus) + r.Post("/p/transcode/{videoID}/start", s.handleTranscodeStart) + r.Get("/p/transcode/{videoID}", s.handleTranscode) + r.Get("/p/preview/{videoID}", s.handlePreview) + r.Get("/p/thumb/{videoID}", s.handleThumb) + }) +} + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ + Sort: "hot", Page: 1, PageSize: 24, + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, mapVideos(items)) +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + size, _ := strconv.Atoi(q.Get("size")) + if size <= 0 { + size = 24 + } + params := catalog.ListParams{ + Keyword: q.Get("q"), + Tag: q.Get("tag"), + Category: q.Get("cat"), + Sort: q.Get("sort"), + Page: page, + PageSize: size, + } + items, total, err := s.Catalog.ListVideos(r.Context(), params) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": mapVideos(items), + "total": total, + "page": params.Page, + "size": params.PageSize, + }) +} + +func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + v, err := s.Catalog.GetVideo(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, err) + return + } + related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ + Sort: "hot", Page: 1, PageSize: 8, + }) + + detail := VideoDetailDTO{ + VideoDTO: mapVideo(v), + VideoSrc: videoSource(v), + Poster: thumbnailURL(v), + Description: v.Description, + EmbedURL: fmt.Sprintf(``, v.ID), + AuthorProfile: AuthorProfile{ + ID: "author-" + v.Author, + Name: v.Author, + Href: "/author/" + v.Author, + Badges: []string{}, + }, + RelatedVideos: filterVideos(mapVideos(related), v.ID), + CommentsList: []Comment{}, + } + writeJSON(w, http.StatusOK, detail) +} + +func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) { + stats, err := s.Catalog.CountTags(r.Context(), fixedtags.Labels) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + 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}) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + likes, err := s.Catalog.IncrementLike(r.Context(), id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"likes": likes}) +} + +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + driveID := chi.URLParam(r, "driveID") + fileID := chi.URLParam(r, "fileID") + s.Proxy.ServeStream(w, r, driveID, fileID) +} + +func (s *Server) handleTranscode(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "videoID") + v, err := s.Catalog.GetVideo(r.Context(), videoID) + if err != nil { + http.NotFound(w, r) + return + } + path := s.transcodePath(v.ID) + if s.transcodeStatus(v.ID) == "ready" { + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Cache-Control", "private, max-age=86400") + http.ServeFile(w, r, path) + return + } + s.startTranscode(v) + w.Header().Set("Retry-After", "3") + writeJSON(w, http.StatusAccepted, map[string]string{"status": s.transcodeStatus(v.ID)}) +} + +func (s *Server) handleTranscodeStatus(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "videoID") + if _, err := s.Catalog.GetVideo(r.Context(), videoID); err != nil { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": s.transcodeStatus(videoID)}) +} + +func (s *Server) handleTranscodeStart(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "videoID") + v, err := s.Catalog.GetVideo(r.Context(), videoID) + if err != nil { + http.NotFound(w, r) + return + } + if s.transcodeStatus(v.ID) != "ready" { + s.startTranscode(v) + } + writeJSON(w, http.StatusAccepted, map[string]string{"status": s.transcodeStatus(v.ID)}) +} + +func (s *Server) startTranscode(v *catalog.Video) { + if s.transcodeStatus(v.ID) == "ready" { + return + } + s.transcodeMu.Lock() + if s.transcodeJobs == nil { + s.transcodeJobs = make(map[string]bool) + } + if s.transcodeJobs[v.ID] { + s.transcodeMu.Unlock() + return + } + s.transcodeJobs[v.ID] = true + s.transcodeMu.Unlock() + + go func() { + defer s.setTranscoding(v.ID, false) + if err := s.generateTranscode(v); err != nil { + log.Printf("[transcode] %s: %v", v.Title, err) + } + }() +} + +func (s *Server) generateTranscode(v *catalog.Video) error { + drv, ok := s.Proxy.Registry.Get(v.DriveID) + if !ok { + return fmt.Errorf("drive not found") + } + link, err := drv.StreamURL(context.Background(), v.FileID) + if err != nil { + return err + } + + ffmpeg := s.FFmpegPath + if ffmpeg == "" { + ffmpeg = "ffmpeg" + } + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-nostdin", + } + if h := buildFFmpegHeaders(link.Headers); h != "" { + args = append(args, "-headers", h) + } + args = append(args, + "-i", link.URL, + "-map", "0:v:0", + "-map", "0:a:0?", + "-c:v", "libx264", + "-preset", "veryfast", + "-tune", "zerolatency", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + "-y", + ) + + dst := s.transcodePath(v.ID) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + tmp := s.transcodeTempPath(v.ID) + _ = os.Remove(tmp) + args = append(args, tmp) + cmd := exec.Command(ffmpeg, args...) + out, err := cmd.CombinedOutput() + if err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg: %w, stderr: %s", err, string(out)) + } + info, err := os.Stat(tmp) + if err != nil { + return err + } + if info.Size() == 0 { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg produced empty file") + } + return os.Rename(tmp, dst) +} + +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "videoID") + v, err := s.Catalog.GetVideo(r.Context(), videoID) + if err != nil { + http.NotFound(w, r) + return + } + if v.PreviewStatus != "ready" { + http.Error(w, "preview not ready", http.StatusNotFound) + return + } + if v.PreviewFileID != "" { + s.Proxy.ServeStream(w, r, v.DriveID, v.PreviewFileID) + return + } + if v.PreviewLocal != "" { + if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) { + http.Error(w, "invalid local path", http.StatusForbidden) + return + } + s.Proxy.ServeLocal(w, r, v.PreviewLocal) + return + } + http.NotFound(w, r) +} + +func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { + videoID := chi.URLParam(r, "videoID") + // 直接读本地 thumbs 目录中 .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 _, err := os.Stat(clean); err != nil { + w.Header().Set("Cache-Control", "no-store") + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "private, max-age=86400") + s.Proxy.ServeLocal(w, r, clean) +} + +// ---------- helpers ---------- + +func mapVideo(v *catalog.Video) VideoDTO { + badges := v.Badges + if badges == nil { + badges = []string{} + } + tags := v.Tags + if tags == nil { + tags = []string{} + } + return VideoDTO{ + ID: v.ID, + Href: "/video/" + v.ID, + Title: v.Title, + Thumbnail: thumbnailURL(v), + PreviewSrc: "/p/preview/" + v.ID, + PreviewDuration: 10, + PreviewStrategy: "teaser-file", + Duration: formatDuration(v.DurationSeconds), + Badges: badges, + Quality: v.Quality, + Author: v.Author, + Views: v.Views, + Favorites: v.Favorites, + Comments: v.Comments, + Likes: v.Likes, + Dislikes: v.Dislikes, + PublishedAt: v.PublishedAt.Format("2006-01-02"), + Tags: tags, + Category: v.Category, + } +} + +func thumbnailURL(v *catalog.Video) string { + if v.ThumbnailURL != "" { + return v.ThumbnailURL + } + return "/p/thumb/" + v.ID +} + +func videoSource(v *catalog.Video) string { + if needsBrowserTranscode(v.Ext) { + return "/p/transcode/" + v.ID + } + return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID) +} + +func needsBrowserTranscode(ext string) bool { + switch strings.ToLower(strings.TrimPrefix(ext, ".")) { + case "avi", "mkv": + return true + default: + return false + } +} + +func buildFFmpegHeaders(h http.Header) string { + if len(h) == 0 { + return "" + } + var sb strings.Builder + for k, vs := range h { + for _, v := range vs { + sb.WriteString(k) + sb.WriteString(": ") + sb.WriteString(v) + sb.WriteString("\r\n") + } + } + return sb.String() +} + +func (s *Server) transcodeStatus(videoID string) string { + if info, err := os.Stat(s.transcodePath(videoID)); err == nil && info.Size() > 0 { + return "ready" + } + s.transcodeMu.Lock() + defer s.transcodeMu.Unlock() + if s.transcodeJobs != nil && s.transcodeJobs[videoID] { + return "processing" + } + return "missing" +} + +func (s *Server) setTranscoding(videoID string, processing bool) { + s.transcodeMu.Lock() + defer s.transcodeMu.Unlock() + if s.transcodeJobs == nil { + s.transcodeJobs = make(map[string]bool) + } + if processing { + s.transcodeJobs[videoID] = true + return + } + delete(s.transcodeJobs, videoID) +} + +func (s *Server) transcodePath(videoID string) string { + return filepath.Join(s.LocalDir, "transcodes", videoID+".mp4") +} + +func (s *Server) transcodeTempPath(videoID string) string { + return filepath.Join(s.LocalDir, "transcodes", videoID+".tmp.mp4") +} + +func mapVideos(vs []*catalog.Video) []VideoDTO { + out := make([]VideoDTO, 0, len(vs)) + for _, v := range vs { + out = append(out, mapVideo(v)) + } + return out +} + +func filterVideos(vs []VideoDTO, exclude string) []VideoDTO { + out := make([]VideoDTO, 0, len(vs)) + for _, v := range vs { + if v.ID != exclude { + out = append(out, v) + } + } + return out +} + +func formatDuration(sec int) string { + if sec <= 0 { + return "00:00" + } + m := sec / 60 + s := sec % 60 + return fmt.Sprintf("%02d:%02d", m, s) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func writeErr(w http.ResponseWriter, code int, err error) { + writeJSON(w, code, map[string]string{"error": err.Error()}) +} diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..195e55c --- /dev/null +++ b/backend/internal/api/api_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/video-site/backend/internal/catalog" +) + +func TestVideoSourceUsesTranscodeForAvi(t *testing.T) { + v := &catalog.Video{ + ID: "video-1", + DriveID: "drive-1", + FileID: "file-1", + Ext: "avi", + } + + got := videoSource(v) + + if got != "/p/transcode/video-1" { + t.Fatalf("video source = %q, want transcode route", got) + } +} + +func TestVideoSourceKeepsDirectStreamForMp4(t *testing.T) { + v := &catalog.Video{ + ID: "video-1", + DriveID: "drive-1", + FileID: "file-1", + Ext: "mp4", + } + + got := videoSource(v) + + if got != "/p/stream/drive-1/file-1" { + t.Fatalf("video source = %q, want direct stream route", got) + } +} + +func TestTranscodeStatusReadyWhenCachedFileExists(t *testing.T) { + s := &Server{LocalDir: t.TempDir()} + videoID := "video-1" + path := s.transcodePath(videoID) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir cache dir: %v", err) + } + if err := os.WriteFile(path, []byte("mp4"), 0o644); err != nil { + t.Fatalf("write cache file: %v", err) + } + + if got := s.transcodeStatus(videoID); got != "ready" { + t.Fatalf("status = %q, want ready", got) + } +} + +func TestTranscodeStatusProcessingWhenJobActive(t *testing.T) { + s := &Server{LocalDir: t.TempDir()} + videoID := "video-1" + s.setTranscoding(videoID, true) + + if got := s.transcodeStatus(videoID); got != "processing" { + t.Fatalf("status = %q, want processing", got) + } +} + +func TestTranscodeTempPathKeepsMp4Extension(t *testing.T) { + s := &Server{LocalDir: t.TempDir()} + + if got := s.transcodeTempPath("video-1"); !strings.HasSuffix(got, ".mp4") { + t.Fatalf("temp transcode path = %q, want .mp4 suffix for ffmpeg muxer detection", got) + } +} + +func TestHandleTagsReturnsFixedTagsOnly(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: "video-1", + DriveID: "drive", + FileID: "file-1", + Title: "女大后入", + Tags: []string{"后入", "女大", "sunny"}, + Category: "random-category", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed video: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) + rr := httptest.NewRecorder() + (&Server{Catalog: cat}).handleTags(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got []struct { + ID string `json:"id"` + Label string `json:"label"` + Count int `json:"count"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + labels := make([]string, 0, len(got)) + for _, tag := range got { + labels = append(labels, tag.Label) + } + want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"} + if !sameStrings(labels, want) { + t.Fatalf("labels = %#v, want %#v", labels, want) + } + if got[0].Count != 1 || got[5].Count != 1 { + t.Fatalf("counts = %#v, want 后入 and 女大 count 1", got) + } +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index ef9b543..ccb683f 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -1,81 +1,81 @@ -package auth - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "net/http" - "time" - - "github.com/video-site/backend/internal/catalog" -) - -const ( - sessionCookie = "vs_admin" - sessionTTL = 24 * time.Hour -) - -type Authenticator struct { - Username string - Password string - Catalog *catalog.Catalog -} - -func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) { - if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 || - subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 { - return false, nil - } - token, err := randomToken() - if err != nil { - return false, err - } - if err := a.Catalog.CreateSession(r.Context(), token, sessionTTL); err != nil { - return false, err - } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, - Value: token, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(sessionTTL), - }) - return true, nil -} - -func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookie); err == nil { - _ = a.Catalog.DeleteSession(r.Context(), c.Value) - } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - }) -} - -func (a *Authenticator) Required(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie(sessionCookie) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - ok, err := a.Catalog.ValidateSession(r.Context(), c.Value) - if err != nil || !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -func randomToken() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "net/http" + "time" + + "github.com/video-site/backend/internal/catalog" +) + +const ( + sessionCookie = "vs_admin" + sessionTTL = 24 * time.Hour +) + +type Authenticator struct { + Username string + Password string + Catalog *catalog.Catalog +} + +func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) { + if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 || + subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 { + return false, nil + } + token, err := randomToken() + if err != nil { + return false, err + } + if err := a.Catalog.CreateSession(r.Context(), token, sessionTTL); err != nil { + return false, err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionTTL), + }) + return true, nil +} + +func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + _ = a.Catalog.DeleteSession(r.Context(), c.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + }) +} + +func (a *Authenticator) Required(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + ok, err := a.Catalog.ValidateSession(r.Context(), c.Value) + if err != nil || !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func randomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index b4ae270..917babb 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -1,479 +1,534 @@ -package catalog - -import ( - "context" - "database/sql" - _ "embed" - "encoding/json" - "fmt" - "strings" - "time" - - _ "modernc.org/sqlite" -) - -//go:embed schema.sql -var schemaSQL string - -type Catalog struct { - db *sql.DB -} - -func Open(path string) (*Catalog, error) { - db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") - if err != nil { - return nil, err - } - if _, err := db.Exec(schemaSQL); err != nil { - db.Close() - return nil, fmt.Errorf("apply schema: %w", err) - } - return &Catalog{db: db}, nil -} - -func (c *Catalog) Close() error { return c.db.Close() } - -// ---------- Video ---------- - -type Video struct { - ID string `json:"id"` - DriveID string `json:"driveId"` - FileID string `json:"fileId"` - ParentID string `json:"parentId"` - Title string `json:"title"` - Author string `json:"author"` - Tags []string `json:"tags"` - DurationSeconds int `json:"durationSeconds"` - Size int64 `json:"size"` - Ext string `json:"ext"` - Quality string `json:"quality"` - ThumbnailURL string `json:"thumbnailUrl"` - PreviewFileID string `json:"previewFileId"` - PreviewLocal string `json:"previewLocal"` - PreviewStatus string `json:"previewStatus"` - Views int `json:"views"` - Favorites int `json:"favorites"` - Comments int `json:"comments"` - Likes int `json:"likes"` - Dislikes int `json:"dislikes"` - Category string `json:"category"` - Badges []string `json:"badges"` - Description string `json:"description"` - PublishedAt time.Time `json:"publishedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error { - tagsJSON, _ := json.Marshal(v.Tags) - badgesJSON, _ := json.Marshal(v.Badges) - now := time.Now().UnixMilli() - if v.CreatedAt.IsZero() { - v.CreatedAt = time.UnixMilli(now) - } - v.UpdatedAt = time.UnixMilli(now) - - _, err := c.db.ExecContext(ctx, ` -INSERT INTO videos ( - id, drive_id, file_id, parent_id, title, author, tags, - duration_seconds, size_bytes, ext, quality, thumbnail_url, - preview_file_id, preview_local, preview_status, - views, favorites, comments, likes, dislikes, - category, badges, description, published_at, created_at, updated_at -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, - ?, ?, ?, - ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ? -) -ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - author = excluded.author, - tags = excluded.tags, - duration_seconds= excluded.duration_seconds, - size_bytes = excluded.size_bytes, - ext = excluded.ext, - quality = excluded.quality, - thumbnail_url = excluded.thumbnail_url, - category = excluded.category, - badges = excluded.badges, - description = excluded.description, - updated_at = excluded.updated_at -`, - v.ID, v.DriveID, v.FileID, v.ParentID, v.Title, v.Author, string(tagsJSON), - v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL, - v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus), - v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes, - v.Category, string(badgesJSON), v.Description, - v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(), - ) - return err -} - -func nullableStatus(s string) string { - if s == "" { - return "pending" - } - return s -} - -func (c *Catalog) UpdatePreview(ctx context.Context, id, previewFileID, previewLocal, status string) error { - _, err := c.db.ExecContext(ctx, - `UPDATE videos SET preview_file_id = ?, preview_local = ?, preview_status = ?, updated_at = ? WHERE id = ?`, - previewFileID, previewLocal, status, time.Now().UnixMilli(), id) - return err -} - -// IncrementLike 原子 +1,返回最新点赞数 -func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) { - tx, err := c.db.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, - `UPDATE videos SET likes = likes + 1, updated_at = ? WHERE id = ?`, - time.Now().UnixMilli(), id); err != nil { - return 0, err - } - var likes int - if err := tx.QueryRowContext(ctx, `SELECT likes FROM videos WHERE id = ?`, id).Scan(&likes); err != nil { - return 0, err - } - if err := tx.Commit(); err != nil { - return 0, err - } - return likes, nil -} - -// VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入) -type VideoMetaPatch struct { - ThumbnailURL string - DurationSeconds int - Category string -} - -func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPatch) error { - parts := []string{} - args := []any{} - if p.ThumbnailURL != "" { - parts = append(parts, "thumbnail_url = ?") - args = append(args, p.ThumbnailURL) - } - if p.DurationSeconds > 0 { - parts = append(parts, "duration_seconds = ?") - args = append(args, p.DurationSeconds) - } - if p.Category != "" { - parts = append(parts, "category = ?") - args = append(args, p.Category) - } - if len(parts) == 0 { - return nil - } - parts = append(parts, "updated_at = ?") - args = append(args, time.Now().UnixMilli()) - args = append(args, id) - q := `UPDATE videos SET ` + strings.Join(parts, ", ") + ` WHERE id = ?` - _, err := c.db.ExecContext(ctx, q, args...) - return err -} - -// ListCategories 聚合所有 category,按视频数降序 -type CategoryStat struct { - Category string - Count int -} - -func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) { - rows, err := c.db.QueryContext(ctx, - `SELECT COALESCE(category, '') AS c, COUNT(*) AS cnt - FROM videos - WHERE category IS NOT NULL AND category != '' - GROUP BY c - ORDER BY cnt DESC, c ASC`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CategoryStat - for rows.Next() { - var s CategoryStat - if err := rows.Scan(&s.Category, &s.Count); err != nil { - return nil, err - } - out = append(out, s) - } - return out, nil -} - -// ListVideosByPreviewStatus 按预览状态列出全部视频,通常用于启动补扫 -func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) { - if limit <= 0 { - limit = 10000 - } - rows, err := c.db.QueryContext(ctx, - `SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? AND preview_status = ? ORDER BY created_at ASC LIMIT ?`, - driveID, status, limit) - 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, nil -} - -func (c *Catalog) GetVideo(ctx context.Context, id string) (*Video, error) { - row := c.db.QueryRowContext(ctx, `SELECT `+allVideoCols+` FROM videos WHERE id = ?`, id) - return scanVideo(row) -} - -type ListParams struct { - Keyword string - Tag string - Category string - Sort string // latest | hot | week | long - Page int - PageSize int -} - -func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) { if p.PageSize <= 0 { - p.PageSize = 24 - } - if p.Page <= 0 { - p.Page = 1 - } - - var where []string - var args []any - if p.Keyword != "" { - where = append(where, "(title LIKE ? OR author LIKE ?)") - like := "%" + p.Keyword + "%" - args = append(args, like, like) - } - if p.Tag != "" { - where = append(where, "tags LIKE ?") - args = append(args, "%\""+p.Tag+"\"%") - } - if p.Category != "" && p.Category != "all" { - where = append(where, "category = ?") - args = append(args, p.Category) - } - - whereSQL := "" - if len(where) > 0 { - whereSQL = " WHERE " + strings.Join(where, " AND ") - } - - orderBy := " ORDER BY published_at DESC" - switch p.Sort { - case "hot": - // 热度 = 点赞数,点赞相同按最新 - orderBy = " ORDER BY likes DESC, published_at DESC" - case "week": - orderBy = " ORDER BY likes DESC" - case "long": - orderBy = " ORDER BY duration_seconds DESC" - } - - // count - var total int - if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil { - return nil, 0, err - } - - // list - offset := (p.Page - 1) * p.PageSize - rows, err := c.db.QueryContext(ctx, - "SELECT "+allVideoCols+" FROM videos"+whereSQL+orderBy+" LIMIT ? OFFSET ?", - append(args, p.PageSize, offset)...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var out []*Video - for rows.Next() { - v, err := scanVideo(rows) - if err != nil { - return nil, 0, err - } - out = append(out, v) - } - return out, total, nil -} - -// ---------- Drive ---------- - -type Drive struct { - ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - RootID string `json:"rootId"` - ScanRootID string `json:"scanRootId"` - Credentials map[string]string `json:"credentials,omitempty"` - Status string `json:"status"` - LastError string `json:"lastError,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error { - cred, _ := json.Marshal(d.Credentials) - now := time.Now().UnixMilli() - if d.CreatedAt.IsZero() { - d.CreatedAt = time.UnixMilli(now) - } - d.UpdatedAt = time.UnixMilli(now) - _, err := c.db.ExecContext(ctx, ` -INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(id) DO UPDATE SET - kind = excluded.kind, - name = excluded.name, - root_id = excluded.root_id, - scan_root_id = excluded.scan_root_id, - credentials = excluded.credentials, - status = excluded.status, - last_error = excluded.last_error, - updated_at = excluded.updated_at -`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError, - d.CreatedAt.UnixMilli(), d.UpdatedAt.UnixMilli()) - return err -} - -func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) { - rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives ORDER BY created_at ASC`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []*Drive - for rows.Next() { - d := &Drive{} - var credsStr string - var createdAt, updatedAt int64 - if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(credsStr), &d.Credentials) - d.CreatedAt = time.UnixMilli(createdAt) - d.UpdatedAt = time.UnixMilli(updatedAt) - out = append(out, d) - } - return out, nil -} - -func (c *Catalog) GetDrive(ctx context.Context, id string) (*Drive, error) { - row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives WHERE id = ?`, id) - d := &Drive{} - var credsStr string - var createdAt, updatedAt int64 - if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(credsStr), &d.Credentials) - d.CreatedAt = time.UnixMilli(createdAt) - d.UpdatedAt = time.UnixMilli(updatedAt) - return d, nil -} - -func (c *Catalog) DeleteDrive(ctx context.Context, id string) error { - _, err := c.db.ExecContext(ctx, `DELETE FROM drives WHERE id = ?`, id) - return err -} - -// ---------- Admin session ---------- - -func (c *Catalog) CreateSession(ctx context.Context, token string, ttl time.Duration) error { - now := time.Now() - _, err := c.db.ExecContext(ctx, - `INSERT INTO admin_sessions (token, created_at, expires_at) VALUES (?, ?, ?)`, - token, now.UnixMilli(), now.Add(ttl).UnixMilli()) - return err -} - -func (c *Catalog) ValidateSession(ctx context.Context, token string) (bool, error) { - var expires int64 - err := c.db.QueryRowContext(ctx, `SELECT expires_at FROM admin_sessions WHERE token = ?`, token).Scan(&expires) - if err == sql.ErrNoRows { - return false, nil - } - if err != nil { - return false, err - } - return time.Now().UnixMilli() < expires, nil -} - -func (c *Catalog) DeleteSession(ctx context.Context, token string) error { - _, err := c.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE token = ?`, token) - return err -} - -// ---------- Settings ---------- - -func (c *Catalog) GetSetting(ctx context.Context, key, defaultValue string) (string, error) { - var v string - err := c.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) - if err == sql.ErrNoRows { - return defaultValue, nil - } - if err != nil { - return "", err - } - return v, nil -} - -func (c *Catalog) SetSetting(ctx context.Context, key, value string) error { - _, err := c.db.ExecContext(ctx, ` -INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at -`, key, value, time.Now().UnixMilli()) - return err -} - -// ---------- helpers ---------- - -const allVideoCols = ` -id, drive_id, file_id, COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'), -duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''), -COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'), -views, favorites, comments, likes, dislikes, -COALESCE(category, ''), COALESCE(badges, '[]'), COALESCE(description, ''), -published_at, created_at, updated_at -` - -type rowScanner interface { - Scan(dest ...any) error -} - -func scanVideo(row rowScanner) (*Video, error) { - v := &Video{} - var tagsJSON, badgesJSON string - var publishedAt, createdAt, updatedAt int64 - err := row.Scan( - &v.ID, &v.DriveID, &v.FileID, &v.ParentID, &v.Title, &v.Author, &tagsJSON, - &v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL, - &v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus, - &v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes, - &v.Category, &badgesJSON, &v.Description, - &publishedAt, &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(tagsJSON), &v.Tags) - _ = json.Unmarshal([]byte(badgesJSON), &v.Badges) - v.PublishedAt = time.UnixMilli(publishedAt) - v.CreatedAt = time.UnixMilli(createdAt) - v.UpdatedAt = time.UnixMilli(updatedAt) - return v, nil -} +package catalog + +import ( + "context" + "database/sql" + _ "embed" + "encoding/json" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +type Catalog struct { + db *sql.DB +} + +func Open(path string) (*Catalog, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + if _, err := db.Exec(schemaSQL); err != nil { + db.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + return &Catalog{db: db}, nil +} + +func (c *Catalog) Close() error { return c.db.Close() } + +// ---------- Video ---------- + +type Video struct { + ID string `json:"id"` + DriveID string `json:"driveId"` + FileID string `json:"fileId"` + ParentID string `json:"parentId"` + Title string `json:"title"` + Author string `json:"author"` + Tags []string `json:"tags"` + DurationSeconds int `json:"durationSeconds"` + Size int64 `json:"size"` + Ext string `json:"ext"` + Quality string `json:"quality"` + ThumbnailURL string `json:"thumbnailUrl"` + PreviewFileID string `json:"previewFileId"` + PreviewLocal string `json:"previewLocal"` + PreviewStatus string `json:"previewStatus"` + Views int `json:"views"` + Favorites int `json:"favorites"` + Comments int `json:"comments"` + Likes int `json:"likes"` + Dislikes int `json:"dislikes"` + Category string `json:"category"` + Badges []string `json:"badges"` + Description string `json:"description"` + PublishedAt time.Time `json:"publishedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error { + tagsJSON, _ := json.Marshal(v.Tags) + badgesJSON, _ := json.Marshal(v.Badges) + now := time.Now().UnixMilli() + if v.CreatedAt.IsZero() { + v.CreatedAt = time.UnixMilli(now) + } + v.UpdatedAt = time.UnixMilli(now) + + _, err := c.db.ExecContext(ctx, ` +INSERT INTO videos ( + id, drive_id, file_id, parent_id, title, author, tags, + duration_seconds, size_bytes, ext, quality, thumbnail_url, + preview_file_id, preview_local, preview_status, + views, favorites, comments, likes, dislikes, + category, badges, description, published_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + author = excluded.author, + tags = excluded.tags, + duration_seconds= excluded.duration_seconds, + size_bytes = excluded.size_bytes, + ext = excluded.ext, + quality = excluded.quality, + thumbnail_url = excluded.thumbnail_url, + category = excluded.category, + badges = excluded.badges, + description = excluded.description, + updated_at = excluded.updated_at +`, + v.ID, v.DriveID, v.FileID, v.ParentID, v.Title, v.Author, string(tagsJSON), + v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL, + v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus), + v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes, + v.Category, string(badgesJSON), v.Description, + v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(), + ) + return err +} + +func nullableStatus(s string) string { + if s == "" { + return "pending" + } + return s +} + +func (c *Catalog) UpdatePreview(ctx context.Context, id, previewFileID, previewLocal, status string) error { + _, err := c.db.ExecContext(ctx, + `UPDATE videos SET preview_file_id = ?, preview_local = ?, preview_status = ?, updated_at = ? WHERE id = ?`, + previewFileID, previewLocal, status, time.Now().UnixMilli(), id) + return err +} + +// IncrementLike 原子 +1,返回最新点赞数 +func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, + `UPDATE videos SET likes = likes + 1, updated_at = ? WHERE id = ?`, + time.Now().UnixMilli(), id); err != nil { + return 0, err + } + var likes int + if err := tx.QueryRowContext(ctx, `SELECT likes FROM videos WHERE id = ?`, id).Scan(&likes); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return likes, nil +} + +// VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入) +type VideoMetaPatch struct { + ThumbnailURL string + DurationSeconds int + Category string + Tags []string + TagsSet bool +} + +func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPatch) error { + parts := []string{} + args := []any{} + if p.ThumbnailURL != "" { + parts = append(parts, "thumbnail_url = ?") + args = append(args, p.ThumbnailURL) + } + if p.DurationSeconds > 0 { + parts = append(parts, "duration_seconds = ?") + args = append(args, p.DurationSeconds) + } + if p.Category != "" { + parts = append(parts, "category = ?") + args = append(args, p.Category) + } + if p.TagsSet { + tagsJSON, _ := json.Marshal(p.Tags) + parts = append(parts, "tags = ?") + args = append(args, string(tagsJSON)) + } + if len(parts) == 0 { + return nil + } + parts = append(parts, "updated_at = ?") + args = append(args, time.Now().UnixMilli()) + args = append(args, id) + q := `UPDATE videos SET ` + strings.Join(parts, ", ") + ` WHERE id = ?` + _, err := c.db.ExecContext(ctx, q, args...) + return err +} + +// ListCategories 聚合所有 category,按视频数降序 +type CategoryStat struct { + Category string + Count int +} + +func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT COALESCE(category, '') AS c, COUNT(*) AS cnt + FROM videos + WHERE category IS NOT NULL AND category != '' + GROUP BY c + ORDER BY cnt DESC, c ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CategoryStat + for rows.Next() { + var s CategoryStat + if err := rows.Scan(&s.Category, &s.Count); err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +type TagStat struct { + Label string + Count int +} + +func (c *Catalog) CountTags(ctx context.Context, labels []string) ([]TagStat, error) { + out := make([]TagStat, 0, len(labels)) + for _, label := range labels { + var count int + if err := c.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM videos WHERE tags LIKE ?`, + "%\""+label+"\"%", + ).Scan(&count); err != nil { + return nil, err + } + out = append(out, TagStat{Label: label, Count: count}) + } + return out, nil +} + +// ListVideosByPreviewStatus 按预览状态列出全部视频,通常用于启动补扫 +func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) { + if limit <= 0 { + limit = 10000 + } + rows, err := c.db.QueryContext(ctx, + `SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? AND preview_status = ? ORDER BY created_at ASC LIMIT ?`, + driveID, status, limit) + 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, nil +} + +// ListVideosNeedingThumbnail returns videos that do not have any cover URL yet. +func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) { + if limit <= 0 { + limit = 10000 + } + rows, err := c.db.QueryContext(ctx, + `SELECT `+allVideoCols+` FROM videos + WHERE drive_id = ? + AND COALESCE(thumbnail_url, '') = '' + ORDER BY created_at ASC + LIMIT ?`, + driveID, limit) + 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, nil +} + +func (c *Catalog) GetVideo(ctx context.Context, id string) (*Video, error) { + row := c.db.QueryRowContext(ctx, `SELECT `+allVideoCols+` FROM videos WHERE id = ?`, id) + return scanVideo(row) +} + +type ListParams struct { + Keyword string + Tag string + Category string + Sort string // latest | hot | week | long + Page int + PageSize int +} + +func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) { + if p.PageSize <= 0 { + p.PageSize = 24 + } + if p.Page <= 0 { + p.Page = 1 + } + + var where []string + var args []any + if p.Keyword != "" { + where = append(where, "(title LIKE ? OR author LIKE ?)") + like := "%" + p.Keyword + "%" + args = append(args, like, like) + } + if p.Tag != "" { + where = append(where, "tags LIKE ?") + args = append(args, "%\""+p.Tag+"\"%") + } + if p.Category != "" && p.Category != "all" { + where = append(where, "category = ?") + args = append(args, p.Category) + } + + whereSQL := "" + if len(where) > 0 { + whereSQL = " WHERE " + strings.Join(where, " AND ") + } + + orderBy := " ORDER BY published_at DESC" + switch p.Sort { + case "hot": + // 热度 = 点赞数,点赞相同按最新 + orderBy = " ORDER BY likes DESC, published_at DESC" + case "week": + orderBy = " ORDER BY likes DESC" + case "long": + orderBy = " ORDER BY duration_seconds DESC" + } + + // count + var total int + if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // list + offset := (p.Page - 1) * p.PageSize + rows, err := c.db.QueryContext(ctx, + "SELECT "+allVideoCols+" FROM videos"+whereSQL+orderBy+" LIMIT ? OFFSET ?", + append(args, p.PageSize, offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []*Video + for rows.Next() { + v, err := scanVideo(rows) + if err != nil { + return nil, 0, err + } + out = append(out, v) + } + return out, total, nil +} + +// ---------- Drive ---------- + +type Drive struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + RootID string `json:"rootId"` + ScanRootID string `json:"scanRootId"` + Credentials map[string]string `json:"credentials,omitempty"` + Status string `json:"status"` + LastError string `json:"lastError,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error { + cred, _ := json.Marshal(d.Credentials) + now := time.Now().UnixMilli() + if d.CreatedAt.IsZero() { + d.CreatedAt = time.UnixMilli(now) + } + d.UpdatedAt = time.UnixMilli(now) + _, err := c.db.ExecContext(ctx, ` +INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + kind = excluded.kind, + name = excluded.name, + root_id = excluded.root_id, + scan_root_id = excluded.scan_root_id, + credentials = excluded.credentials, + status = excluded.status, + last_error = excluded.last_error, + updated_at = excluded.updated_at +`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError, + d.CreatedAt.UnixMilli(), d.UpdatedAt.UnixMilli()) + return err +} + +func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) { + rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Drive + for rows.Next() { + d := &Drive{} + var credsStr string + var createdAt, updatedAt int64 + if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(credsStr), &d.Credentials) + d.CreatedAt = time.UnixMilli(createdAt) + d.UpdatedAt = time.UnixMilli(updatedAt) + out = append(out, d) + } + return out, nil +} + +func (c *Catalog) GetDrive(ctx context.Context, id string) (*Drive, error) { + row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives WHERE id = ?`, id) + d := &Drive{} + var credsStr string + var createdAt, updatedAt int64 + if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(credsStr), &d.Credentials) + d.CreatedAt = time.UnixMilli(createdAt) + d.UpdatedAt = time.UnixMilli(updatedAt) + return d, nil +} + +func (c *Catalog) DeleteDrive(ctx context.Context, id string) error { + _, err := c.db.ExecContext(ctx, `DELETE FROM drives WHERE id = ?`, id) + return err +} + +// ---------- Admin session ---------- + +func (c *Catalog) CreateSession(ctx context.Context, token string, ttl time.Duration) error { + now := time.Now() + _, err := c.db.ExecContext(ctx, + `INSERT INTO admin_sessions (token, created_at, expires_at) VALUES (?, ?, ?)`, + token, now.UnixMilli(), now.Add(ttl).UnixMilli()) + return err +} + +func (c *Catalog) ValidateSession(ctx context.Context, token string) (bool, error) { + var expires int64 + err := c.db.QueryRowContext(ctx, `SELECT expires_at FROM admin_sessions WHERE token = ?`, token).Scan(&expires) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return time.Now().UnixMilli() < expires, nil +} + +func (c *Catalog) DeleteSession(ctx context.Context, token string) error { + _, err := c.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE token = ?`, token) + return err +} + +// ---------- Settings ---------- + +func (c *Catalog) GetSetting(ctx context.Context, key, defaultValue string) (string, error) { + var v string + err := c.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + if err == sql.ErrNoRows { + return defaultValue, nil + } + if err != nil { + return "", err + } + return v, nil +} + +func (c *Catalog) SetSetting(ctx context.Context, key, value string) error { + _, err := c.db.ExecContext(ctx, ` +INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) +ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at +`, key, value, time.Now().UnixMilli()) + return err +} + +// ---------- helpers ---------- + +const allVideoCols = ` +id, drive_id, file_id, COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'), +duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''), +COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'), +views, favorites, comments, likes, dislikes, +COALESCE(category, ''), COALESCE(badges, '[]'), COALESCE(description, ''), +published_at, created_at, updated_at +` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanVideo(row rowScanner) (*Video, error) { + v := &Video{} + var tagsJSON, badgesJSON string + var publishedAt, createdAt, updatedAt int64 + err := row.Scan( + &v.ID, &v.DriveID, &v.FileID, &v.ParentID, &v.Title, &v.Author, &tagsJSON, + &v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL, + &v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus, + &v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes, + &v.Category, &badgesJSON, &v.Description, + &publishedAt, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(tagsJSON), &v.Tags) + _ = json.Unmarshal([]byte(badgesJSON), &v.Badges) + v.PublishedAt = time.UnixMilli(publishedAt) + v.CreatedAt = time.UnixMilli(createdAt) + v.UpdatedAt = time.UnixMilli(updatedAt) + return v, nil +} diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index 7d12f53..7e136d9 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -1,72 +1,72 @@ --- 视频元数据主表 -CREATE TABLE IF NOT EXISTS videos ( - id TEXT PRIMARY KEY, -- - 拼接的稳定 ID - drive_id TEXT NOT NULL, - file_id TEXT NOT NULL, - parent_id TEXT, - title TEXT NOT NULL, - author TEXT, - tags TEXT, -- JSON array - duration_seconds INTEGER DEFAULT 0, - size_bytes INTEGER DEFAULT 0, - ext TEXT, - quality TEXT, -- HD / SD - thumbnail_url TEXT, - preview_file_id TEXT, -- 回写网盘后的 teaser file id - preview_local TEXT, -- 本地 teaser 路径(兜底) - preview_status TEXT DEFAULT 'pending', -- pending / ready / failed - views INTEGER DEFAULT 0, - favorites INTEGER DEFAULT 0, - comments INTEGER DEFAULT 0, - likes INTEGER DEFAULT 0, - dislikes INTEGER DEFAULT 0, - category TEXT, - badges TEXT, -- JSON array - description TEXT, - published_at INTEGER NOT NULL, -- unix ms - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_videos_drive ON videos(drive_id, file_id); -CREATE INDEX IF NOT EXISTS idx_videos_pub ON videos(published_at DESC); -CREATE INDEX IF NOT EXISTS idx_videos_views ON videos(views DESC); - --- 网盘账户 -CREATE TABLE IF NOT EXISTS drives ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, -- quark / p115 / wopan - name TEXT NOT NULL, - root_id TEXT NOT NULL DEFAULT '0', - scan_root_id TEXT, -- 扫描起点(默认 root_id) - credentials TEXT, -- JSON: cookie / refresh_token 等 - status TEXT DEFAULT 'disconnected', -- disconnected / ok / error - last_error TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - --- 扫描任务状态 -CREATE TABLE IF NOT EXISTS scans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - drive_id TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER, - scanned INTEGER DEFAULT 0, - added INTEGER DEFAULT 0, - error TEXT -); - --- 管理后台 session(简单 token 存储) -CREATE TABLE IF NOT EXISTS admin_sessions ( - token TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL -); - --- 全局 key-value 设置(preview 开关等) -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL -); +-- 视频元数据主表 +CREATE TABLE IF NOT EXISTS videos ( + id TEXT PRIMARY KEY, -- - 拼接的稳定 ID + drive_id TEXT NOT NULL, + file_id TEXT NOT NULL, + parent_id TEXT, + title TEXT NOT NULL, + author TEXT, + tags TEXT, -- JSON array + duration_seconds INTEGER DEFAULT 0, + size_bytes INTEGER DEFAULT 0, + ext TEXT, + quality TEXT, -- HD / SD + thumbnail_url TEXT, + preview_file_id TEXT, -- 回写网盘后的 teaser file id + preview_local TEXT, -- 本地 teaser 路径(兜底) + preview_status TEXT DEFAULT 'pending', -- pending / ready / failed + views INTEGER DEFAULT 0, + favorites INTEGER DEFAULT 0, + comments INTEGER DEFAULT 0, + likes INTEGER DEFAULT 0, + dislikes INTEGER DEFAULT 0, + category TEXT, + badges TEXT, -- JSON array + description TEXT, + published_at INTEGER NOT NULL, -- unix ms + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_videos_drive ON videos(drive_id, file_id); +CREATE INDEX IF NOT EXISTS idx_videos_pub ON videos(published_at DESC); +CREATE INDEX IF NOT EXISTS idx_videos_views ON videos(views DESC); + +-- 网盘账户 +CREATE TABLE IF NOT EXISTS drives ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan + name TEXT NOT NULL, + root_id TEXT NOT NULL DEFAULT '0', + scan_root_id TEXT, -- 扫描起点(默认 root_id) + credentials TEXT, -- JSON: cookie / refresh_token 等 + status TEXT DEFAULT 'disconnected', -- disconnected / ok / error + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- 扫描任务状态 +CREATE TABLE IF NOT EXISTS scans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drive_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + scanned INTEGER DEFAULT 0, + added INTEGER DEFAULT 0, + error TEXT +); + +-- 管理后台 session(简单 token 存储) +CREATE TABLE IF NOT EXISTS admin_sessions ( + token TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- 全局 key-value 设置(preview 开关等) +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0537aa6..66a5363 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,117 +1,117 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -type Config struct { - Server Server `yaml:"server"` - Storage Storage `yaml:"storage"` - Scanner Scanner `yaml:"scanner"` - Preview Preview `yaml:"preview"` - Drives []Drive `yaml:"drives"` -} - -type Server struct { - Listen string `yaml:"listen"` - Admin Admin `yaml:"admin"` - SessionSecret string `yaml:"session_secret"` -} - -type Admin struct { - Username string `yaml:"username"` - Password string `yaml:"password"` -} - -type Storage struct { - DBPath string `yaml:"db_path"` - LocalPreviewDir string `yaml:"local_preview_dir"` -} - -type Scanner struct { - IntervalSeconds int `yaml:"interval_seconds"` - MaxDepth int `yaml:"max_depth"` - VideoExtensions []string `yaml:"video_extensions"` -} - -type Preview struct { - Enabled bool `yaml:"enabled"` - FFmpegPath string `yaml:"ffmpeg_path"` - FFprobePath string `yaml:"ffprobe_path"` - DurationSeconds int `yaml:"duration_seconds"` - Width int `yaml:"width"` - Segments int `yaml:"segments"` - RemoteDir string `yaml:"remote_dir"` -} - -// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB -// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 -type Drive struct { - ID string `yaml:"id"` - Kind string `yaml:"kind"` // quark / p115 / wopan - Name string `yaml:"name"` - RootID string `yaml:"root_id"` - Params map[string]string `yaml:"params,omitempty"` -} - -// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回 -func Load(path string) (*Config, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - example := filepath.Join(filepath.Dir(path), "config.example.yaml") - data, err := os.ReadFile(example) - if err != nil { - return nil, fmt.Errorf("config not found and example missing: %w", err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - return nil, fmt.Errorf("write default config: %w", err) - } - } - - b, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - var c Config - if err := yaml.Unmarshal(b, &c); err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } - c.applyDefaults() - return &c, nil -} - -func (c *Config) applyDefaults() { - if c.Server.Listen == "" { - c.Server.Listen = ":8080" - } - if c.Storage.DBPath == "" { - c.Storage.DBPath = "./data/video-site.db" - } - if c.Storage.LocalPreviewDir == "" { - c.Storage.LocalPreviewDir = "./data/previews" - } - if c.Scanner.MaxDepth == 0 { - c.Scanner.MaxDepth = 5 - } - if len(c.Scanner.VideoExtensions) == 0 { - c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"} - } - if c.Preview.FFmpegPath == "" { - c.Preview.FFmpegPath = "ffmpeg" - } - if c.Preview.FFprobePath == "" { - c.Preview.FFprobePath = "ffprobe" - } - if c.Preview.DurationSeconds == 0 { - c.Preview.DurationSeconds = 9 - } - if c.Preview.Width == 0 { - c.Preview.Width = 480 - } - if c.Preview.Segments == 0 { - c.Preview.Segments = 3 - } -} +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server Server `yaml:"server"` + Storage Storage `yaml:"storage"` + Scanner Scanner `yaml:"scanner"` + Preview Preview `yaml:"preview"` + Drives []Drive `yaml:"drives"` +} + +type Server struct { + Listen string `yaml:"listen"` + Admin Admin `yaml:"admin"` + SessionSecret string `yaml:"session_secret"` +} + +type Admin struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type Storage struct { + DBPath string `yaml:"db_path"` + LocalPreviewDir string `yaml:"local_preview_dir"` +} + +type Scanner struct { + IntervalSeconds int `yaml:"interval_seconds"` + MaxDepth int `yaml:"max_depth"` + VideoExtensions []string `yaml:"video_extensions"` +} + +type Preview struct { + Enabled bool `yaml:"enabled"` + FFmpegPath string `yaml:"ffmpeg_path"` + FFprobePath string `yaml:"ffprobe_path"` + DurationSeconds int `yaml:"duration_seconds"` + Width int `yaml:"width"` + Segments int `yaml:"segments"` + RemoteDir string `yaml:"remote_dir"` +} + +// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB +// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 +type Drive struct { + ID string `yaml:"id"` + Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan + Name string `yaml:"name"` + RootID string `yaml:"root_id"` + Params map[string]string `yaml:"params,omitempty"` +} + +// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回 +func Load(path string) (*Config, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + example := filepath.Join(filepath.Dir(path), "config.example.yaml") + data, err := os.ReadFile(example) + if err != nil { + return nil, fmt.Errorf("config not found and example missing: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return nil, fmt.Errorf("write default config: %w", err) + } + } + + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var c Config + if err := yaml.Unmarshal(b, &c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + c.applyDefaults() + return &c, nil +} + +func (c *Config) applyDefaults() { + if c.Server.Listen == "" { + c.Server.Listen = ":8080" + } + if c.Storage.DBPath == "" { + c.Storage.DBPath = "./data/video-site.db" + } + if c.Storage.LocalPreviewDir == "" { + c.Storage.LocalPreviewDir = "./data/previews" + } + if c.Scanner.MaxDepth == 0 { + c.Scanner.MaxDepth = 5 + } + if len(c.Scanner.VideoExtensions) == 0 { + c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"} + } + if c.Preview.FFmpegPath == "" { + c.Preview.FFmpegPath = "ffmpeg" + } + if c.Preview.FFprobePath == "" { + c.Preview.FFprobePath = "ffprobe" + } + if c.Preview.DurationSeconds == 0 { + c.Preview.DurationSeconds = 9 + } + if c.Preview.Width == 0 { + c.Preview.Width = 480 + } + if c.Preview.Segments == 0 { + c.Preview.Segments = 3 + } +} diff --git a/backend/internal/drives/iface.go b/backend/internal/drives/iface.go index 0f33703..9e2a55e 100644 --- a/backend/internal/drives/iface.go +++ b/backend/internal/drives/iface.go @@ -1,64 +1,65 @@ -package drives - -import ( - "context" - "errors" - "io" - "net/http" - "time" -) - -// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。 -type Drive interface { - // Kind 返回驱动代号:"quark" / "p115" / "wopan" - Kind() string - - // ID 返回该盘在 catalog 中的唯一标识 - ID() string - - // Init 完成登录态校验;登录态由 Authenticator 另行获取后注入 - Init(ctx context.Context) error - - // List 列指定目录下的直接子项 - List(ctx context.Context, dirID string) ([]Entry, error) - - // Stat 拿到单个文件的元数据 - Stat(ctx context.Context, fileID string) (*Entry, error) - - // StreamURL 返回一次性直链 + 必须的请求头 - // 代理层据此回源,透传 Range - StreamURL(ctx context.Context, fileID string) (*StreamLink, error) - - // Upload 把本地流写入指定目录,返回新文件 fileID - // 用于 scanner 把 teaser 写回网盘 - Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) - - // EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID - // 例如传 "/previews" 会保证根下有一个 previews 目录 - EnsureDir(ctx context.Context, pathFromRoot string) (string, error) - - // RootID 返回根目录 fileID - RootID() string -} - -type Entry struct { - ID string - Name string - Size int64 - IsDir bool - ParentID string - MimeType string - ModTime time.Time - - // 部分网盘额外信息 - Category int // 1=视频 (quark) -} - -type StreamLink struct { - URL string - Headers http.Header - Expires time.Time -} - -// ErrNotSupported 代表某家盘不支持某操作 -var ErrNotSupported = errors.New("operation not supported by this drive") +package drives + +import ( + "context" + "errors" + "io" + "net/http" + "time" +) + +// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。 +type Drive interface { + // Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan" + Kind() string + + // ID 返回该盘在 catalog 中的唯一标识 + ID() string + + // Init 完成登录态校验;登录态由 Authenticator 另行获取后注入 + Init(ctx context.Context) error + + // List 列指定目录下的直接子项 + List(ctx context.Context, dirID string) ([]Entry, error) + + // Stat 拿到单个文件的元数据 + Stat(ctx context.Context, fileID string) (*Entry, error) + + // StreamURL 返回一次性直链 + 必须的请求头 + // 代理层据此回源,透传 Range + StreamURL(ctx context.Context, fileID string) (*StreamLink, error) + + // Upload 把本地流写入指定目录,返回新文件 fileID + // 用于 scanner 把 teaser 写回网盘 + Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) + + // EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID + // 例如传 "/previews" 会保证根下有一个 previews 目录 + EnsureDir(ctx context.Context, pathFromRoot string) (string, error) + + // RootID 返回根目录 fileID + RootID() string +} + +type Entry struct { + ID string + Name string + Size int64 + IsDir bool + ParentID string + MimeType string + ModTime time.Time + + // 部分网盘额外信息 + Category int // 1=视频 (quark) + ThumbnailURL string // 网盘侧已提供的快速缩略图 +} + +type StreamLink struct { + URL string + Headers http.Header + Expires time.Time +} + +// ErrNotSupported 代表某家盘不支持某操作 +var ErrNotSupported = errors.New("operation not supported by this drive") diff --git a/backend/internal/drives/p115/driver.go b/backend/internal/drives/p115/driver.go index 85d24d4..afa7d34 100644 --- a/backend/internal/drives/p115/driver.go +++ b/backend/internal/drives/p115/driver.go @@ -1,217 +1,217 @@ -package p115 - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "path" - "strings" - "time" - - sdk "github.com/SheltonZhu/115driver/pkg/driver" - "github.com/video-site/backend/internal/drives" -) - -type Driver struct { - id string - cookie string - rootID string - client *sdk.Pan115Client - ua string -} - -type Config struct { - ID string - Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx" - RootID string // 默认 "0" - UA string // 默认 UA115Browser -} - -func New(c Config) *Driver { - rootID := c.RootID - if rootID == "" { - rootID = "0" - } - ua := c.UA - if ua == "" { - ua = sdk.UA115Browser - } - return &Driver{ - id: c.ID, - cookie: c.Cookie, - rootID: rootID, - ua: ua, - } -} - -func (d *Driver) Kind() string { return "p115" } -func (d *Driver) ID() string { return d.id } -func (d *Driver) RootID() string { return d.rootID } - -func (d *Driver) Init(ctx context.Context) error { - cr := &sdk.Credential{} - if err := cr.FromCookie(d.cookie); err != nil { - return fmt.Errorf("parse cookie: %w", err) - } - d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr) - return d.client.LoginCheck() -} - -func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { - files, err := d.client.ListWithLimit(dirID, sdk.FileListLimit) - if err != nil { - return nil, fmt.Errorf("115 list: %w", err) - } - if files == nil { - return nil, nil - } - out := make([]drives.Entry, 0, len(*files)) - for _, f := range *files { - out = append(out, fileToEntry(&f, dirID)) - } - return out, nil -} - -func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { - f, err := d.client.GetFile(fileID) - if err != nil { - return nil, fmt.Errorf("115 stat: %w", err) - } - if f == nil { - return nil, errors.New("115 stat: not found") - } - e := fileToEntry(f, f.ParentID) - return &e, nil -} - -func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { - // 需要先拿到 pickCode - f, err := d.client.GetFile(fileID) - if err != nil { - return nil, fmt.Errorf("115 get file: %w", err) - } - info, err := d.client.DownloadWithUA(f.PickCode, d.ua) - if err != nil { - return nil, fmt.Errorf("115 download url: %w", err) - } - if info == nil || info.Url.Url == "" { - return nil, errors.New("115 download url: empty") - } - - headers := http.Header{} - headers.Set("User-Agent", d.ua) - // 115 直链会返回一组 Cookie / Referer,info.Header 里带了 - for k, vs := range info.Header { - for _, v := range vs { - headers.Add(k, v) - } - } - - return &drives.StreamLink{ - URL: info.Url.Url, - Headers: headers, - Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量 - }, nil -} - -func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { - // 115 上传流程比较复杂:RapidUpload -> OSS 分片 - // 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS - buf, err := io.ReadAll(r) - if err != nil { - return "", err - } - rs := strings.NewReader(string(buf)) - if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil { - return "", fmt.Errorf("115 upload: %w", err) - } - // RapidUploadOrByOSS 目前没返回 fileID,需要回查 - files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit) - if err != nil { - return "", fmt.Errorf("115 upload verify: %w", err) - } - if files != nil { - for _, f := range *files { - if !f.IsDirectory && f.Name == name { - return f.FileID, nil - } - } - } - return "", errors.New("115 upload: file not found after upload") -} - -func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { - parts := splitPath(pathFromRoot) - currentID := d.rootID - for _, name := range parts { - childID, err := d.findChildDir(ctx, currentID, name) - if err != nil { - return "", err - } - if childID == "" { - id, err := d.client.Mkdir(currentID, name) - if err != nil { - return "", fmt.Errorf("115 mkdir %s: %w", name, err) - } - childID = id - } - currentID = childID - } - return currentID, nil -} - -func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { - entries, err := d.List(ctx, parent) - if err != nil { - return "", err - } - for _, e := range entries { - if e.IsDir && e.Name == name { - return e.ID, nil - } - } - return "", nil -} - -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - return strings.Split(p, "/") -} - -func fileToEntry(f *sdk.File, parentID string) drives.Entry { - return drives.Entry{ - ID: f.FileID, - Name: f.Name, - Size: f.Size, - IsDir: f.IsDirectory, - ParentID: parentID, - MimeType: guessMime(f.Name), - ModTime: f.UpdateTime, - } -} - -func guessMime(name string) string { - ext := strings.ToLower(path.Ext(name)) - switch ext { - case ".mp4": - return "video/mp4" - case ".mkv": - return "video/x-matroska" - case ".mov": - return "video/quicktime" - case ".webm": - return "video/webm" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - } - return "application/octet-stream" -} - -var _ drives.Drive = (*Driver)(nil) +package p115 + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + + sdk "github.com/SheltonZhu/115driver/pkg/driver" + "github.com/video-site/backend/internal/drives" +) + +type Driver struct { + id string + cookie string + rootID string + client *sdk.Pan115Client + ua string +} + +type Config struct { + ID string + Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx" + RootID string // 默认 "0" + UA string // 默认 UA115Browser +} + +func New(c Config) *Driver { + rootID := c.RootID + if rootID == "" { + rootID = "0" + } + ua := c.UA + if ua == "" { + ua = sdk.UA115Browser + } + return &Driver{ + id: c.ID, + cookie: c.Cookie, + rootID: rootID, + ua: ua, + } +} + +func (d *Driver) Kind() string { return "p115" } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +func (d *Driver) Init(ctx context.Context) error { + cr := &sdk.Credential{} + if err := cr.FromCookie(d.cookie); err != nil { + return fmt.Errorf("parse cookie: %w", err) + } + d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr) + return d.client.LoginCheck() +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + files, err := d.client.ListWithLimit(dirID, sdk.FileListLimit) + if err != nil { + return nil, fmt.Errorf("115 list: %w", err) + } + if files == nil { + return nil, nil + } + out := make([]drives.Entry, 0, len(*files)) + for _, f := range *files { + out = append(out, fileToEntry(&f, dirID)) + } + return out, nil +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + f, err := d.client.GetFile(fileID) + if err != nil { + return nil, fmt.Errorf("115 stat: %w", err) + } + if f == nil { + return nil, errors.New("115 stat: not found") + } + e := fileToEntry(f, f.ParentID) + return &e, nil +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + // 需要先拿到 pickCode + f, err := d.client.GetFile(fileID) + if err != nil { + return nil, fmt.Errorf("115 get file: %w", err) + } + info, err := d.client.DownloadWithUA(f.PickCode, d.ua) + if err != nil { + return nil, fmt.Errorf("115 download url: %w", err) + } + if info == nil || info.Url.Url == "" { + return nil, errors.New("115 download url: empty") + } + + headers := http.Header{} + headers.Set("User-Agent", d.ua) + // 115 直链会返回一组 Cookie / Referer,info.Header 里带了 + for k, vs := range info.Header { + for _, v := range vs { + headers.Add(k, v) + } + } + + return &drives.StreamLink{ + URL: info.Url.Url, + Headers: headers, + Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量 + }, nil +} + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + // 115 上传流程比较复杂:RapidUpload -> OSS 分片 + // 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS + buf, err := io.ReadAll(r) + if err != nil { + return "", err + } + rs := strings.NewReader(string(buf)) + if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil { + return "", fmt.Errorf("115 upload: %w", err) + } + // RapidUploadOrByOSS 目前没返回 fileID,需要回查 + files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit) + if err != nil { + return "", fmt.Errorf("115 upload verify: %w", err) + } + if files != nil { + for _, f := range *files { + if !f.IsDirectory && f.Name == name { + return f.FileID, nil + } + } + } + return "", errors.New("115 upload: file not found after upload") +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + parts := splitPath(pathFromRoot) + currentID := d.rootID + for _, name := range parts { + childID, err := d.findChildDir(ctx, currentID, name) + if err != nil { + return "", err + } + if childID == "" { + id, err := d.client.Mkdir(currentID, name) + if err != nil { + return "", fmt.Errorf("115 mkdir %s: %w", name, err) + } + childID = id + } + currentID = childID + } + return currentID, nil +} + +func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { + entries, err := d.List(ctx, parent) + if err != nil { + return "", err + } + for _, e := range entries { + if e.IsDir && e.Name == name { + return e.ID, nil + } + } + return "", nil +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +func fileToEntry(f *sdk.File, parentID string) drives.Entry { + return drives.Entry{ + ID: f.FileID, + Name: f.Name, + Size: f.Size, + IsDir: f.IsDirectory, + ParentID: parentID, + MimeType: guessMime(f.Name), + ModTime: f.UpdateTime, + } +} + +func guessMime(name string) string { + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + } + return "application/octet-stream" +} + +var _ drives.Drive = (*Driver)(nil) diff --git a/backend/internal/drives/pikpak/auth.go b/backend/internal/drives/pikpak/auth.go new file mode 100644 index 0000000..c028769 --- /dev/null +++ b/backend/internal/drives/pikpak/auth.go @@ -0,0 +1,283 @@ +package pikpak + +import ( + "context" + "crypto/md5" + "crypto/sha1" + "encoding/hex" + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +var androidAlgorithms = []string{ + "SOP04dGzk0TNO7t7t9ekDbAmx+eq0OI1ovEx", + "nVBjhYiND4hZ2NCGyV5beamIr7k6ifAsAbl", + "Ddjpt5B/Cit6EDq2a6cXgxY9lkEIOw4yC1GDF28KrA", + "VVCogcmSNIVvgV6U+AochorydiSymi68YVNGiz", + "u5ujk5sM62gpJOsB/1Gu/zsfgfZO", + "dXYIiBOAHZgzSruaQ2Nhrqc2im", + "z5jUTBSIpBN9g4qSJGlidNAutX6", + "KJE2oveZ34du/g1tiimm", +} + +var webAlgorithms = []string{ + "C9qPpZLN8ucRTaTiUMWYS9cQvWOE", + "+r6CQVxjzJV6LCV", + "F", + "pFJRC", + "9WXYIDGrwTCz2OiVlgZa90qpECPD6olt", + "/750aCr4lm/Sly/c", + "RB+DT/gZCrbV", + "", + "CyLsf7hdkIRxRm215hl", + "7xHvLi2tOYP0Y92b", + "ZGTXXxu8E/MIWaEDB+Sm/", + "1UI3", + "E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO", + "ihtqpG6FMt65+Xk+tWUH2", + "NhXXU9rg4XXdzo7u5o", +} + +var pcAlgorithms = []string{ + "KHBJ07an7ROXDoK7Db", + "G6n399rSWkl7WcQmw5rpQInurc1DkLmLJqE", + "JZD1A3M4x+jBFN62hkr7VDhkkZxb9g3rWqRZqFAAb", + "fQnw/AmSlbbI91Ik15gpddGgyU7U", + "/Dv9JdPYSj3sHiWjouR95NTQff", + "yGx2zuTjbWENZqecNI+edrQgqmZKP", + "ljrbSzdHLwbqcRn", + "lSHAsqCkGDGxQqqwrVu", + "TsWXI81fD1", + "vk7hBjawK/rOSrSWajtbMk95nfgf3", +} + +func (d *Driver) applyPlatformDefaults() { + switch d.platform { + case "android": + d.clientID = "YNxT9w7GMdWvEOKa" + d.clientSecret = "dbw2OtmVEeuUvIptb1Coyg" + d.clientVersion = "1.53.2" + d.packageName = "com.pikcloud.pikpak" + d.algorithms = androidAlgorithms + d.userAgent = buildAndroidUserAgent(d.deviceID, d.clientID, d.packageName, "2.0.6.206003", d.clientVersion, d.packageName, d.userID) + case "pc": + d.clientID = "YvtoWO6GNHiuCl7x" + d.clientSecret = "1NIH5R1IEe2pAxZE3hv3uA" + d.clientVersion = "undefined" + d.packageName = "mypikpak.com" + d.algorithms = pcAlgorithms + d.userAgent = "MainWindow Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) PikPak/2.6.11.4955 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36" + default: + d.platform = "web" + d.clientID = "YUMx5nI8ZU8Ap8pm" + d.clientSecret = "dbw2OtmVEeuUvIptb1Coyg" + d.clientVersion = "2.0.0" + d.packageName = "mypikpak.com" + d.algorithms = webAlgorithms + d.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36" + } +} + +func (d *Driver) login(ctx context.Context) error { + if d.username == "" || d.password == "" { + return fmt.Errorf("pikpak username or password is empty") + } + if d.captchaToken == "" { + if err := d.refreshCaptchaTokenInLogin(ctx, getAction(http.MethodPost, signinURL), d.username); err != nil { + return err + } + } + + var out authResp + var e errResp + res, err := d.client.R(). + SetContext(ctx). + SetError(&e). + SetResult(&out). + SetQueryParam("client_id", d.clientID). + SetBody(map[string]any{ + "captcha_token": d.captchaToken, + "client_id": d.clientID, + "client_secret": d.clientSecret, + "username": d.username, + "password": d.password, + }). + Post(signinURL) + if err != nil { + return err + } + if e.isError() { + return &e + } + if res.IsError() { + return fmt.Errorf("pikpak signin http %d: %s", res.StatusCode(), string(res.Body())) + } + d.applyAuth(out) + return nil +} + +func (d *Driver) refresh(ctx context.Context, refreshToken string) error { + if refreshToken == "" { + return fmt.Errorf("pikpak refresh_token is empty") + } + var out authResp + var e errResp + res, err := d.client.R(). + SetContext(ctx). + SetHeader("User-Agent", ""). + SetError(&e). + SetResult(&out). + SetQueryParam("client_id", d.clientID). + SetBody(map[string]any{ + "client_id": d.clientID, + "client_secret": d.clientSecret, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + }). + Post(tokenURL) + if err != nil { + return err + } + if e.isError() { + if e.ErrorCode == 4126 && d.username != "" && d.password != "" { + return d.login(ctx) + } + return &e + } + if res.IsError() { + return fmt.Errorf("pikpak refresh http %d: %s", res.StatusCode(), string(res.Body())) + } + d.applyAuth(out) + return nil +} + +func (d *Driver) applyAuth(out authResp) { + d.accessToken = out.AccessToken + d.refreshToken = out.RefreshToken + d.userID = out.Sub + if d.platform == "android" { + d.userAgent = buildAndroidUserAgent(d.deviceID, d.clientID, d.packageName, "2.0.6.206003", d.clientVersion, d.packageName, d.userID) + } + d.persistTokens() +} + +func (d *Driver) persistTokens() { + if d.onTokenUpdate != nil { + d.onTokenUpdate(d.accessToken, d.refreshToken, d.captchaToken, d.deviceID) + } +} + +func (d *Driver) refreshCaptchaTokenAtLogin(ctx context.Context, action, userID string) error { + timestamp, sign := d.captchaSign() + return d.refreshCaptchaToken(ctx, action, map[string]string{ + "client_version": d.clientVersion, + "package_name": d.packageName, + "user_id": userID, + "timestamp": timestamp, + "captcha_sign": sign, + }) +} + +func (d *Driver) refreshCaptchaTokenInLogin(ctx context.Context, action, username string) error { + meta := make(map[string]string) + if ok, _ := regexp.MatchString(`\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*`, username); ok { + meta["email"] = username + } else if len(username) >= 11 && len(username) <= 18 { + meta["phone_number"] = username + } else { + meta["username"] = username + } + return d.refreshCaptchaToken(ctx, action, meta) +} + +func (d *Driver) refreshCaptchaToken(ctx context.Context, action string, meta map[string]string) error { + var e errResp + var out captchaTokenResponse + req := d.client.R(). + SetContext(ctx). + SetHeader("User-Agent", d.userAgent). + SetHeader("X-Device-ID", d.deviceID). + SetError(&e). + SetResult(&out). + SetQueryParam("client_id", d.clientID). + SetBody(captchaTokenRequest{ + Action: action, + CaptchaToken: d.captchaToken, + ClientID: d.clientID, + DeviceID: d.deviceID, + Meta: meta, + RedirectURI: "xlaccsdk01://xbase.cloud/callback?state=harbor", + }) + if d.accessToken != "" { + req.SetHeader("Authorization", "Bearer "+d.accessToken) + } + res, err := req.Post(captchaInitURL) + if err != nil { + return err + } + if e.isError() { + return &e + } + if res.IsError() { + return fmt.Errorf("pikpak captcha http %d: %s", res.StatusCode(), string(res.Body())) + } + if out.URL != "" { + return fmt.Errorf("pikpak captcha verification required: %s", out.URL) + } + d.captchaToken = out.CaptchaToken + d.persistTokens() + return nil +} + +func (d *Driver) captchaSign() (timestamp, sign string) { + timestamp = fmt.Sprint(time.Now().UnixMilli()) + raw := fmt.Sprint(d.clientID, d.clientVersion, d.packageName, d.deviceID, timestamp) + for _, algorithm := range d.algorithms { + raw = md5Hex(raw + algorithm) + } + return timestamp, "1." + raw +} + +func getAction(method, rawURL string) string { + match := regexp.MustCompile(`://[^/]+((/[^/\s?#]+)*)`).FindStringSubmatch(rawURL) + if len(match) < 2 { + return method + ":" + rawURL + } + return method + ":" + match[1] +} + +func generateDeviceSign(deviceID, packageName string) string { + signatureBase := fmt.Sprintf("%s%s%s%s", deviceID, packageName, "1", "appkey") + sha1Hash := sha1.Sum([]byte(signatureBase)) + md5Hash := md5.Sum([]byte(hex.EncodeToString(sha1Hash[:]))) + return fmt.Sprintf("div101.%s%s", deviceID, hex.EncodeToString(md5Hash[:])) +} + +func buildAndroidUserAgent(deviceID, clientID, appName, sdkVersion, clientVersion, packageName, userID string) string { + deviceSign := generateDeviceSign(deviceID, packageName) + var sb strings.Builder + sb.WriteString(fmt.Sprintf("ANDROID-%s/%s ", appName, clientVersion)) + sb.WriteString("protocolVersion/200 accesstype/ ") + sb.WriteString(fmt.Sprintf("clientid/%s ", clientID)) + sb.WriteString(fmt.Sprintf("clientversion/%s ", clientVersion)) + sb.WriteString("action_type/ networktype/WIFI sessionid/ ") + sb.WriteString(fmt.Sprintf("deviceid/%s ", deviceID)) + sb.WriteString("providername/NONE ") + sb.WriteString(fmt.Sprintf("devicesign/%s ", deviceSign)) + sb.WriteString("refresh_token/ ") + sb.WriteString(fmt.Sprintf("sdkversion/%s ", sdkVersion)) + sb.WriteString(fmt.Sprintf("datetime/%d ", time.Now().UnixMilli())) + sb.WriteString(fmt.Sprintf("usrno/%s ", userID)) + sb.WriteString(fmt.Sprintf("appname/android-%s ", appName)) + sb.WriteString("session_origin/ grant_type/ appid/ clientip/ ") + sb.WriteString("devicename/Xiaomi_M2004j7ac osversion/13 platformversion/10 accessmode/ devicemodel/M2004J7AC ") + return sb.String() +} + +func md5Hex(raw string) string { + sum := md5.Sum([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/drives/pikpak/driver.go b/backend/internal/drives/pikpak/driver.go new file mode 100644 index 0000000..6491311 --- /dev/null +++ b/backend/internal/drives/pikpak/driver.go @@ -0,0 +1,333 @@ +package pikpak + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/video-site/backend/internal/drives" +) + +const ( + filesURL = "https://api-drive.mypikpak.net/drive/v1/files" + signinURL = "https://user.mypikpak.net/v1/auth/signin" + tokenURL = "https://user.mypikpak.net/v1/auth/token" + captchaInitURL = "https://user.mypikpak.net/v1/shield/captcha/init" +) + +type Driver struct { + id string + rootID string + username string + password string + platform string + refreshToken string + accessToken string + captchaToken string + deviceID string + userID string + disableMediaLink bool + + clientID string + clientSecret string + clientVersion string + packageName string + algorithms []string + userAgent string + + client *resty.Client + onTokenUpdate func(access, refresh, captcha, deviceID string) +} + +type Config struct { + ID string + Username string + Password string + Platform string + RefreshToken string + AccessToken string + CaptchaToken string + DeviceID string + RootID string + DisableMediaLink bool + OnTokenUpdate func(access, refresh, captcha, deviceID string) +} + +func New(c Config) *Driver { + rootID := strings.TrimSpace(c.RootID) + if rootID == "0" { + rootID = "" + } + platform := strings.ToLower(strings.TrimSpace(c.Platform)) + if platform == "" { + platform = "web" + } + deviceID := strings.TrimSpace(c.DeviceID) + if deviceID == "" { + seed := c.Username + c.Password + if seed == "" { + seed = c.ID + } + deviceID = md5Hex(seed) + } + d := &Driver{ + id: c.ID, + rootID: rootID, + username: c.Username, + password: c.Password, + platform: platform, + refreshToken: c.RefreshToken, + accessToken: c.AccessToken, + captchaToken: c.CaptchaToken, + deviceID: deviceID, + disableMediaLink: c.DisableMediaLink, + onTokenUpdate: c.OnTokenUpdate, + client: resty.New(). + SetTimeout(30*time.Second). + SetHeader("Accept", "application/json, text/plain, */*"), + } + d.applyPlatformDefaults() + return d +} + +func (d *Driver) Kind() string { return "pikpak" } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +func (d *Driver) Init(ctx context.Context) error { + if d.refreshToken != "" { + if err := d.refresh(ctx, d.refreshToken); err != nil { + return err + } + } else { + if err := d.login(ctx); err != nil { + return err + } + } + if err := d.refreshCaptchaTokenAtLogin(ctx, getAction(http.MethodGet, filesURL), d.userID); err != nil { + return err + } + d.persistTokens() + return nil +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + if dirID == "" { + dirID = d.rootID + } + files, err := d.getFiles(ctx, dirID) + if err != nil { + return nil, err + } + out := make([]drives.Entry, 0, len(files)) + for _, f := range files { + out = append(out, fileToEntry(f, dirID)) + } + return out, nil +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + var f file + err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "_magic": "2021", + "usage": "FETCH", + "thumbnail_size": "SIZE_LARGE", + }) + }, &f) + if err != nil { + return nil, fmt.Errorf("pikpak stat: %w", err) + } + e := fileToEntry(f, "") + return &e, nil +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + var f file + usage := "FETCH" + if !d.disableMediaLink { + usage = "CACHE" + } + err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "_magic": "2021", + "usage": usage, + "thumbnail_size": "SIZE_LARGE", + }) + }, &f) + if err != nil { + return nil, fmt.Errorf("pikpak download url: %w", err) + } + + url := f.WebContentLink + expires := time.Now().Add(10 * time.Minute) + if !d.disableMediaLink { + if m, ok := pickMediaLink(f.Medias); ok { + url = m.Link.URL + if !m.Link.Expire.IsZero() { + expires = m.Link.Expire + } + } + } + if url == "" { + return nil, errors.New("pikpak download url: empty") + } + headers := http.Header{} + if d.userAgent != "" { + headers.Set("User-Agent", d.userAgent) + } + return &drives.StreamLink{ + URL: url, + Headers: headers, + Expires: expires, + }, nil +} + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + return "", drives.ErrNotSupported +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + return "", drives.ErrNotSupported +} + +func (d *Driver) getFiles(ctx context.Context, parentID string) ([]file, error) { + out := make([]file, 0) + pageToken := "first" + for pageToken != "" { + if pageToken == "first" { + pageToken = "" + } + query := map[string]string{ + "parent_id": parentID, + "thumbnail_size": "SIZE_LARGE", + "with_audit": "true", + "limit": "100", + "filters": `{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}`, + "page_token": pageToken, + } + var resp filesResp + if err := d.request(ctx, filesURL, http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(query) + }, &resp); err != nil { + return nil, fmt.Errorf("pikpak list: %w", err) + } + out = append(out, resp.Files...) + pageToken = resp.NextPageToken + } + return out, nil +} + +func (d *Driver) request(ctx context.Context, url, method string, configure func(*resty.Request), out any) error { + return d.requestOnce(ctx, url, method, configure, out, true) +} + +func (d *Driver) requestOnce(ctx context.Context, url, method string, configure func(*resty.Request), out any, retry bool) error { + req := d.client.R(). + SetContext(ctx). + SetHeader("User-Agent", d.userAgent). + SetHeader("X-Device-ID", d.deviceID). + SetHeader("X-Captcha-Token", d.captchaToken) + if d.accessToken != "" { + req.SetHeader("Authorization", "Bearer "+d.accessToken) + } + if configure != nil { + configure(req) + } + if out != nil { + req.SetResult(out) + } + var e errResp + req.SetError(&e) + + res, err := req.Execute(method, url) + if err != nil { + return err + } + if e.isError() { + switch e.ErrorCode { + case 4122, 4121, 16: + if retry { + if err := d.refresh(ctx, d.refreshToken); err != nil { + return err + } + return d.requestOnce(ctx, url, method, configure, out, false) + } + case 9: + if retry { + if err := d.refreshCaptchaTokenAtLogin(ctx, getAction(method, url), d.userID); err != nil { + return err + } + return d.requestOnce(ctx, url, method, configure, out, false) + } + } + return &e + } + if res.IsError() { + return fmt.Errorf("pikpak http %d: %s", res.StatusCode(), string(res.Body())) + } + return nil +} + +func pickMediaLink(items []media) (media, bool) { + if len(items) == 0 { + return media{}, false + } + for _, m := range items { + if m.IsOrigin && m.Link.URL != "" { + return m, true + } + } + for _, m := range items { + if m.IsDefault && m.Link.URL != "" { + return m, true + } + } + for _, m := range items { + if m.Link.URL != "" { + return m, true + } + } + return media{}, false +} + +func guessMime(name string) string { + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".avi": + return "video/x-msvideo" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + } + return "application/octet-stream" +} + +func ParseBoolDefault(raw string, def bool) bool { + if raw == "" { + return def + } + v, err := strconv.ParseBool(raw) + if err != nil { + return def + } + return v +} + +var _ drives.Drive = (*Driver)(nil) diff --git a/backend/internal/drives/pikpak/driver_test.go b/backend/internal/drives/pikpak/driver_test.go new file mode 100644 index 0000000..48bb315 --- /dev/null +++ b/backend/internal/drives/pikpak/driver_test.go @@ -0,0 +1,103 @@ +package pikpak + +import ( + "testing" + "time" + + "github.com/video-site/backend/internal/drives" +) + +func TestNewDefaults(t *testing.T) { + d := New(Config{ + ID: "pikpak-main", + Username: "user@example.com", + Password: "secret", + RootID: "0", + }) + + if d.Kind() != "pikpak" { + t.Fatalf("kind = %q, want pikpak", d.Kind()) + } + if d.ID() != "pikpak-main" { + t.Fatalf("id = %q, want pikpak-main", d.ID()) + } + if d.RootID() != "" { + t.Fatalf("root id = %q, want empty PikPak root", d.RootID()) + } + if d.platform != "web" { + t.Fatalf("platform = %q, want web", d.platform) + } + if d.deviceID == "" { + t.Fatal("device id should be generated") + } + if d.userAgent == "" { + t.Fatal("user agent should be selected") + } +} + +func TestFileToEntry(t *testing.T) { + mod := time.Date(2026, 5, 10, 12, 30, 0, 0, time.UTC) + f := file{ + ID: "file-id", + Name: "movie.mp4", + Kind: "drive#file", + Size: "12345", + ThumbnailLink: "https://thumbnail.example/movie.jpg", + ModifiedTime: mod, + } + + got := fileToEntry(f, "parent-id") + + if got.ID != "file-id" { + t.Fatalf("id = %q, want file-id", got.ID) + } + if got.Name != "movie.mp4" { + t.Fatalf("name = %q, want movie.mp4", got.Name) + } + if got.IsDir { + t.Fatal("file should not be a directory") + } + if got.Size != 12345 { + t.Fatalf("size = %d, want 12345", got.Size) + } + if got.ParentID != "parent-id" { + t.Fatalf("parent id = %q, want parent-id", got.ParentID) + } + if got.MimeType != "video/mp4" { + t.Fatalf("mime = %q, want video/mp4", got.MimeType) + } + if got.ThumbnailURL != "https://thumbnail.example/movie.jpg" { + t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL) + } + if !got.ModTime.Equal(mod) { + t.Fatalf("mod time = %v, want %v", got.ModTime, mod) + } +} + +func TestFolderToEntry(t *testing.T) { + f := file{ + ID: "folder-id", + Name: "Videos", + Kind: "drive#folder", + } + + got := fileToEntry(f, "") + + if !got.IsDir { + t.Fatal("folder should be a directory") + } + if got.Size != 0 { + t.Fatalf("size = %d, want 0", got.Size) + } +} + +func TestUnsupportedUploadOperations(t *testing.T) { + d := New(Config{ID: "pikpak-main"}) + + if _, err := d.EnsureDir(nil, "/previews"); err != drives.ErrNotSupported { + t.Fatalf("EnsureDir error = %v, want ErrNotSupported", err) + } + if _, err := d.Upload(nil, "", "preview.mp4", nil, 0); err != drives.ErrNotSupported { + t.Fatalf("Upload error = %v, want ErrNotSupported", err) + } +} diff --git a/backend/internal/drives/pikpak/types.go b/backend/internal/drives/pikpak/types.go new file mode 100644 index 0000000..3d2a5cc --- /dev/null +++ b/backend/internal/drives/pikpak/types.go @@ -0,0 +1,87 @@ +package pikpak + +import ( + "fmt" + "strconv" + "time" + + "github.com/video-site/backend/internal/drives" +) + +type filesResp struct { + Files []file `json:"files"` + NextPageToken string `json:"next_page_token"` +} + +type file struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + CreatedTime time.Time `json:"created_time"` + ModifiedTime time.Time `json:"modified_time"` + Hash string `json:"hash"` + Size string `json:"size"` + ThumbnailLink string `json:"thumbnail_link"` + WebContentLink string `json:"web_content_link"` + Medias []media `json:"medias"` +} + +type media struct { + Link struct { + URL string `json:"url"` + Token string `json:"token"` + Expire time.Time `json:"expire"` + } `json:"link"` + IsDefault bool `json:"is_default"` + IsOrigin bool `json:"is_origin"` + Priority int `json:"priority"` +} + +type authResp struct { + RefreshToken string `json:"refresh_token"` + AccessToken string `json:"access_token"` + Sub string `json:"sub"` +} + +type errResp struct { + ErrorCode int64 `json:"error_code"` + ErrorMsg string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +func (e *errResp) isError() bool { + return e.ErrorCode != 0 || e.ErrorMsg != "" || e.ErrorDescription != "" +} + +func (e *errResp) Error() string { + return fmt.Sprintf("pikpak error_code=%d error=%s description=%s", e.ErrorCode, e.ErrorMsg, e.ErrorDescription) +} + +type captchaTokenRequest struct { + Action string `json:"action"` + CaptchaToken string `json:"captcha_token"` + ClientID string `json:"client_id"` + DeviceID string `json:"device_id"` + Meta map[string]string `json:"meta"` + RedirectURI string `json:"redirect_uri"` +} + +type captchaTokenResponse struct { + CaptchaToken string `json:"captcha_token"` + ExpiresIn int64 `json:"expires_in"` + URL string `json:"url"` +} + +func fileToEntry(f file, parentID string) drives.Entry { + size, _ := strconv.ParseInt(f.Size, 10, 64) + return drives.Entry{ + ID: f.ID, + Name: f.Name, + Size: size, + IsDir: f.Kind == "drive#folder", + ParentID: parentID, + MimeType: guessMime(f.Name), + ModTime: f.ModifiedTime, + ThumbnailURL: f.ThumbnailLink, + } +} diff --git a/backend/internal/drives/quark/driver.go b/backend/internal/drives/quark/driver.go index 17a8465..76396c4 100644 --- a/backend/internal/drives/quark/driver.go +++ b/backend/internal/drives/quark/driver.go @@ -1,345 +1,345 @@ -package quark - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "path" - "strconv" - "strings" - "time" - - "github.com/go-resty/resty/v2" - "github.com/video-site/backend/internal/drives" -) - -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" - 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 -} - -type Config struct { - ID string - Cookie string - RootID string - UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致 - OnCookieUpdate func(cookie string) -} - -func New(c Config) *Driver { - rootID := c.RootID - if rootID == "" { - rootID = "0" - } - d := &Driver{ - id: c.ID, - cookie: c.Cookie, - rootID: rootID, - ua: defaultUA, - referer: defaultReferer, - apiBase: defaultAPI, - pr: defaultPR, - useTranscodingAddress: c.UseTranscodingAddress, - onCookieUpdate: c.OnCookieUpdate, - } - d.client = resty.New(). - SetTimeout(30 * time.Second). - SetHeader("Accept", "application/json, text/plain, */*"). - SetHeader("Referer", d.referer). - SetHeader("User-Agent", d.ua) - return d -} - -func (d *Driver) Kind() string { return "quark" } -func (d *Driver) ID() string { return d.id } -func (d *Driver) RootID() string { return d.rootID } - -// ---------- 公共请求 ---------- - -type resp struct { - Status int `json:"status"` - Code int `json:"code"` - Message string `json:"message"` -} - -func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error { - req := d.client.R(). - SetContext(ctx). - SetHeader("Cookie", d.cookie). - SetQueryParam("pr", d.pr). - SetQueryParam("fr", "pc") - if query != nil { - req.SetQueryParams(query) - } - if body != nil { - req.SetBody(body) - } - if out != nil { - req.SetResult(out) - } - var e resp - req.SetError(&e) - - res, err := req.Execute(method, d.apiBase+path) - if err != nil { - return err - } - - // 处理 cookie 刷新(__puus) - for _, ck := range res.Cookies() { - if ck.Name == "__puus" { - d.cookie = setCookieValue(d.cookie, "__puus", ck.Value) - if d.onCookieUpdate != nil { - d.onCookieUpdate(d.cookie) - } - } - } - - if e.Status >= 400 || e.Code != 0 { - if e.Message == "" { - return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code) - } - return errors.New(e.Message) - } - return nil -} - -func (d *Driver) Init(ctx context.Context) error { - return d.request(ctx, "/config", http.MethodGet, nil, nil, nil) -} - -// ---------- 列目录 ---------- - -type file struct { - Fid string `json:"fid"` - FileName string `json:"file_name"` - Size int64 `json:"size"` - Category int `json:"category"` - File bool `json:"file"` - UpdatedAt int64 `json:"updated_at"` -} - -type sortResp struct { - Data struct { - List []file `json:"list"` - } `json:"data"` - Metadata struct { - Total int `json:"_total"` - } `json:"metadata"` -} - -func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { - var out []drives.Entry - page := 1 - size := 100 - for { - q := map[string]string{ - "pdir_fid": dirID, - "_size": strconv.Itoa(size), - "_page": strconv.Itoa(page), - "_fetch_total": "1", - "fetch_all_file": "1", - "fetch_risk_file_name": "1", - } - var r sortResp - if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil { - return nil, fmt.Errorf("quark list: %w", err) - } - for _, f := range r.Data.List { - out = append(out, fileToEntry(&f, dirID)) - } - if page*size >= r.Metadata.Total { - break - } - page++ - } - return out, nil -} - -func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { - // 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息 - return nil, drives.ErrNotSupported -} - -// ---------- 下载直链 ---------- - -type downResp struct { - Data []struct { - DownloadUrl string `json:"download_url"` - } `json:"data"` -} - -func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { - body := map[string]any{"fids": []string{fileID}} - var r downResp - if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil { - return nil, fmt.Errorf("quark download: %w", err) - } - if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" { - return nil, errors.New("quark download: empty url") - } - - headers := http.Header{} - headers.Set("User-Agent", d.ua) - headers.Set("Referer", d.referer) - headers.Set("Cookie", d.cookie) - - return &drives.StreamLink{ - URL: r.Data[0].DownloadUrl, - Headers: headers, - Expires: time.Now().Add(10 * time.Minute), - }, nil -} - -// ---------- 创建目录 ---------- - -type mkdirResp struct { - Data struct { - Fid string `json:"fid"` - } `json:"data"` -} - -func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) { - body := map[string]any{ - "dir_init_lock": false, - "dir_path": "", - "file_name": name, - "pdir_fid": parentID, - } - var r mkdirResp - if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil { - return "", fmt.Errorf("quark mkdir: %w", err) - } - return r.Data.Fid, nil -} - -func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { - parts := splitPath(pathFromRoot) - currentID := d.rootID - for _, name := range parts { - childID, err := d.findChildDir(ctx, currentID, name) - if err != nil { - return "", err - } - if childID == "" { - id, err := d.MakeDir(ctx, currentID, name) - if err != nil { - return "", err - } - childID = id - } - currentID = childID - } - return currentID, nil -} - -func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { - entries, err := d.List(ctx, parent) - if err != nil { - return "", err - } - for _, e := range entries { - if e.IsDir && e.Name == name { - return e.ID, nil - } - } - return "", nil -} - -// ---------- 上传(第一版不实现,走本地 teaser 兜底) ---------- - -func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { - return "", drives.ErrNotSupported -} - -// ---------- helpers ---------- - -func fileToEntry(f *file, parentID string) drives.Entry { - return drives.Entry{ - ID: f.Fid, - Name: f.FileName, - Size: f.Size, - IsDir: !f.File, - ParentID: parentID, - MimeType: guessMime(f.FileName), - ModTime: time.UnixMilli(f.UpdatedAt), - Category: f.Category, - } -} - -func guessMime(name string) string { - ext := strings.ToLower(path.Ext(name)) - switch ext { - case ".mp4": - return "video/mp4" - case ".mkv": - return "video/x-matroska" - case ".mov": - return "video/quicktime" - case ".webm": - return "video/webm" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - } - return "application/octet-stream" -} - -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - return strings.Split(p, "/") -} - -// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加 -func setCookieValue(cookie, key, value string) string { - if cookie == "" { - return key + "=" + value - } - parts := strings.Split(cookie, ";") - var out []string - found := false - for _, p := range parts { - kv := strings.TrimSpace(p) - if kv == "" { - continue - } - eq := strings.IndexByte(kv, '=') - if eq < 0 { - out = append(out, kv) - continue - } - if kv[:eq] == key { - out = append(out, key+"="+value) - found = true - } else { - out = append(out, kv) - } - } - if !found { - out = append(out, key+"="+value) - } - return strings.Join(out, "; ") -} - -var _ drives.Drive = (*Driver)(nil) +package quark + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/video-site/backend/internal/drives" +) + +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" + 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 +} + +type Config struct { + ID string + Cookie string + RootID string + UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致 + OnCookieUpdate func(cookie string) +} + +func New(c Config) *Driver { + rootID := c.RootID + if rootID == "" { + rootID = "0" + } + d := &Driver{ + id: c.ID, + cookie: c.Cookie, + rootID: rootID, + ua: defaultUA, + referer: defaultReferer, + apiBase: defaultAPI, + pr: defaultPR, + useTranscodingAddress: c.UseTranscodingAddress, + onCookieUpdate: c.OnCookieUpdate, + } + d.client = resty.New(). + SetTimeout(30 * time.Second). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Referer", d.referer). + SetHeader("User-Agent", d.ua) + return d +} + +func (d *Driver) Kind() string { return "quark" } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +// ---------- 公共请求 ---------- + +type resp struct { + Status int `json:"status"` + Code int `json:"code"` + Message string `json:"message"` +} + +func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error { + req := d.client.R(). + SetContext(ctx). + SetHeader("Cookie", d.cookie). + SetQueryParam("pr", d.pr). + SetQueryParam("fr", "pc") + if query != nil { + req.SetQueryParams(query) + } + if body != nil { + req.SetBody(body) + } + if out != nil { + req.SetResult(out) + } + var e resp + req.SetError(&e) + + res, err := req.Execute(method, d.apiBase+path) + if err != nil { + return err + } + + // 处理 cookie 刷新(__puus) + for _, ck := range res.Cookies() { + if ck.Name == "__puus" { + d.cookie = setCookieValue(d.cookie, "__puus", ck.Value) + if d.onCookieUpdate != nil { + d.onCookieUpdate(d.cookie) + } + } + } + + if e.Status >= 400 || e.Code != 0 { + if e.Message == "" { + return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code) + } + return errors.New(e.Message) + } + return nil +} + +func (d *Driver) Init(ctx context.Context) error { + return d.request(ctx, "/config", http.MethodGet, nil, nil, nil) +} + +// ---------- 列目录 ---------- + +type file struct { + Fid string `json:"fid"` + FileName string `json:"file_name"` + Size int64 `json:"size"` + Category int `json:"category"` + File bool `json:"file"` + UpdatedAt int64 `json:"updated_at"` +} + +type sortResp struct { + Data struct { + List []file `json:"list"` + } `json:"data"` + Metadata struct { + Total int `json:"_total"` + } `json:"metadata"` +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + var out []drives.Entry + page := 1 + size := 100 + for { + q := map[string]string{ + "pdir_fid": dirID, + "_size": strconv.Itoa(size), + "_page": strconv.Itoa(page), + "_fetch_total": "1", + "fetch_all_file": "1", + "fetch_risk_file_name": "1", + } + var r sortResp + if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil { + return nil, fmt.Errorf("quark list: %w", err) + } + for _, f := range r.Data.List { + out = append(out, fileToEntry(&f, dirID)) + } + if page*size >= r.Metadata.Total { + break + } + page++ + } + return out, nil +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + // 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息 + return nil, drives.ErrNotSupported +} + +// ---------- 下载直链 ---------- + +type downResp struct { + Data []struct { + DownloadUrl string `json:"download_url"` + } `json:"data"` +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + body := map[string]any{"fids": []string{fileID}} + var r downResp + if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil { + return nil, fmt.Errorf("quark download: %w", err) + } + if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" { + return nil, errors.New("quark download: empty url") + } + + headers := http.Header{} + headers.Set("User-Agent", d.ua) + headers.Set("Referer", d.referer) + headers.Set("Cookie", d.cookie) + + return &drives.StreamLink{ + URL: r.Data[0].DownloadUrl, + Headers: headers, + Expires: time.Now().Add(10 * time.Minute), + }, nil +} + +// ---------- 创建目录 ---------- + +type mkdirResp struct { + Data struct { + Fid string `json:"fid"` + } `json:"data"` +} + +func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) { + body := map[string]any{ + "dir_init_lock": false, + "dir_path": "", + "file_name": name, + "pdir_fid": parentID, + } + var r mkdirResp + if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil { + return "", fmt.Errorf("quark mkdir: %w", err) + } + return r.Data.Fid, nil +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + parts := splitPath(pathFromRoot) + currentID := d.rootID + for _, name := range parts { + childID, err := d.findChildDir(ctx, currentID, name) + if err != nil { + return "", err + } + if childID == "" { + id, err := d.MakeDir(ctx, currentID, name) + if err != nil { + return "", err + } + childID = id + } + currentID = childID + } + return currentID, nil +} + +func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { + entries, err := d.List(ctx, parent) + if err != nil { + return "", err + } + for _, e := range entries { + if e.IsDir && e.Name == name { + return e.ID, nil + } + } + return "", nil +} + +// ---------- 上传(第一版不实现,走本地 teaser 兜底) ---------- + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + return "", drives.ErrNotSupported +} + +// ---------- helpers ---------- + +func fileToEntry(f *file, parentID string) drives.Entry { + return drives.Entry{ + ID: f.Fid, + Name: f.FileName, + Size: f.Size, + IsDir: !f.File, + ParentID: parentID, + MimeType: guessMime(f.FileName), + ModTime: time.UnixMilli(f.UpdatedAt), + Category: f.Category, + } +} + +func guessMime(name string) string { + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + } + return "application/octet-stream" +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加 +func setCookieValue(cookie, key, value string) string { + if cookie == "" { + return key + "=" + value + } + parts := strings.Split(cookie, ";") + var out []string + found := false + for _, p := range parts { + kv := strings.TrimSpace(p) + if kv == "" { + continue + } + eq := strings.IndexByte(kv, '=') + if eq < 0 { + out = append(out, kv) + continue + } + if kv[:eq] == key { + out = append(out, key+"="+value) + found = true + } else { + out = append(out, kv) + } + } + if !found { + out = append(out, key+"="+value) + } + return strings.Join(out, "; ") +} + +var _ drives.Drive = (*Driver)(nil) diff --git a/backend/internal/drives/wopan/driver.go b/backend/internal/drives/wopan/driver.go index 18f86f3..3b003af 100644 --- a/backend/internal/drives/wopan/driver.go +++ b/backend/internal/drives/wopan/driver.go @@ -1,231 +1,231 @@ -package wopan - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path" - "strings" - "time" - - sdk "github.com/OpenListTeam/wopan-sdk-go" - "github.com/video-site/backend/internal/drives" -) - -// Driver 封装联通沃盘 -type Driver struct { - id string - rootID string - familyID string - accessToken string - refreshToken string - client *sdk.WoClient - onTokenUpdate func(access, refresh string) -} - -type Config struct { - ID string - AccessToken string - RefreshToken string - FamilyID string // 空则走个人空间,有值则走家庭空间 - RootID string // 根目录 ID,默认 "0" - // 当 SDK 刷新 token 时回调,便于持久化 - OnTokenUpdate func(access, refresh string) -} - -func New(c Config) *Driver { - rootID := c.RootID - if rootID == "" { - rootID = "0" - } - return &Driver{ - id: c.ID, - rootID: rootID, - familyID: c.FamilyID, - accessToken: c.AccessToken, - refreshToken: c.RefreshToken, - onTokenUpdate: c.OnTokenUpdate, - } -} - -func (d *Driver) Kind() string { return "wopan" } -func (d *Driver) ID() string { return d.id } -func (d *Driver) RootID() string { - return d.rootID -} - -func (d *Driver) Init(ctx context.Context) error { - d.client = sdk.DefaultWithRefreshToken(d.refreshToken) - d.client.SetAccessToken(d.accessToken) - d.client.OnRefreshToken(func(access, refresh string) { - d.accessToken = access - d.refreshToken = refresh - if d.onTokenUpdate != nil { - d.onTokenUpdate(access, refresh) - } - }) - // InitData 会触发一次 token 校验 - return d.client.InitData() -} - -func (d *Driver) spaceType() string { - if d.familyID != "" { - return sdk.SpaceTypeFamily - } - return sdk.SpaceTypePersonal -} - -func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { - var result []drives.Entry - pageNum := 0 - pageSize := 100 - for { - 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 { - result = append(result, fileToEntry(f, dirID)) - } - if len(data.Files) < pageSize { - break - } - pageNum++ - } - return result, nil -} - -func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { - // 沃盘 SDK 没有单文件查询,退化为遍历父目录 —— 这里第一版只在 scanner 路径使用 List,Stat 保留 stub - return nil, drives.ErrNotSupported -} - -func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { - data, err := d.client.GetDownloadUrlV2([]string{fileID}) - if err != nil { - return nil, fmt.Errorf("wopan download url: %w", err) - } - if len(data.List) == 0 { - return nil, fmt.Errorf("wopan download url: empty response") - } - return &drives.StreamLink{ - URL: data.List[0].DownloadUrl, - Headers: http.Header{}, - Expires: time.Now().Add(10 * time.Minute), - }, nil -} - -func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { - // wopan SDK 要求 *os.File,先把流落到临时文件再上传 - tmp, err := os.CreateTemp("", "wopan-upload-*.tmp") - if err != nil { - return "", err - } - defer func() { - tmp.Close() - os.Remove(tmp.Name()) - }() - if _, err := io.Copy(tmp, r); err != nil { - return "", err - } - if _, err := tmp.Seek(0, 0); err != nil { - return "", err - } - fid, err := d.client.Upload2C(d.spaceType(), sdk.Upload2CFile{ - Name: name, - Size: size, - Content: tmp, - ContentType: "application/octet-stream", - }, parentID, d.familyID, sdk.Upload2COption{Ctx: ctx}) - if err != nil { - return "", fmt.Errorf("wopan upload: %w", err) - } - return fid, nil -} - -func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { - parts := splitPath(pathFromRoot) - currentID := d.rootID - for _, name := range parts { - childID, err := d.findChildDir(ctx, currentID, name) - if err != nil { - return "", err - } - if childID == "" { - resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID) - if err != nil { - return "", fmt.Errorf("wopan mkdir %s: %w", name, err) - } - childID = resp.Id - } - currentID = childID - } - return currentID, nil -} - -func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { - entries, err := d.List(ctx, parent) - if err != nil { - return "", err - } - for _, e := range entries { - if e.IsDir && e.Name == name { - return e.ID, nil - } - } - return "", nil -} - -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - return strings.Split(p, "/") -} - -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.Fid - if id == "" { - id = f.Id - } - if isDir && !strings.HasSuffix(name, "/") { - // 不改 name,只标志 - } - return drives.Entry{ - ID: id, - Name: name, - Size: f.Size, - IsDir: isDir, - ParentID: parentID, - MimeType: guessMime(name), - ModTime: mod, - } -} - -func guessMime(name string) string { - ext := strings.ToLower(path.Ext(name)) - switch ext { - case ".mp4": - return "video/mp4" - case ".mkv": - return "video/x-matroska" - case ".mov": - return "video/quicktime" - case ".webm": - return "video/webm" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - } - return "application/octet-stream" -} - -// 确保实现接口 -var _ drives.Drive = (*Driver)(nil) +package wopan + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + "time" + + sdk "github.com/OpenListTeam/wopan-sdk-go" + "github.com/video-site/backend/internal/drives" +) + +// Driver 封装联通沃盘 +type Driver struct { + id string + rootID string + familyID string + accessToken string + refreshToken string + client *sdk.WoClient + onTokenUpdate func(access, refresh string) +} + +type Config struct { + ID string + AccessToken string + RefreshToken string + FamilyID string // 空则走个人空间,有值则走家庭空间 + RootID string // 根目录 ID,默认 "0" + // 当 SDK 刷新 token 时回调,便于持久化 + OnTokenUpdate func(access, refresh string) +} + +func New(c Config) *Driver { + rootID := c.RootID + if rootID == "" { + rootID = "0" + } + return &Driver{ + id: c.ID, + rootID: rootID, + familyID: c.FamilyID, + accessToken: c.AccessToken, + refreshToken: c.RefreshToken, + onTokenUpdate: c.OnTokenUpdate, + } +} + +func (d *Driver) Kind() string { return "wopan" } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { + return d.rootID +} + +func (d *Driver) Init(ctx context.Context) error { + d.client = sdk.DefaultWithRefreshToken(d.refreshToken) + d.client.SetAccessToken(d.accessToken) + d.client.OnRefreshToken(func(access, refresh string) { + d.accessToken = access + d.refreshToken = refresh + if d.onTokenUpdate != nil { + d.onTokenUpdate(access, refresh) + } + }) + // InitData 会触发一次 token 校验 + return d.client.InitData() +} + +func (d *Driver) spaceType() string { + if d.familyID != "" { + return sdk.SpaceTypeFamily + } + return sdk.SpaceTypePersonal +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + var result []drives.Entry + pageNum := 0 + pageSize := 100 + for { + 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 { + result = append(result, fileToEntry(f, dirID)) + } + if len(data.Files) < pageSize { + break + } + pageNum++ + } + return result, nil +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + // 沃盘 SDK 没有单文件查询,退化为遍历父目录 —— 这里第一版只在 scanner 路径使用 List,Stat 保留 stub + return nil, drives.ErrNotSupported +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + data, err := d.client.GetDownloadUrlV2([]string{fileID}) + if err != nil { + return nil, fmt.Errorf("wopan download url: %w", err) + } + if len(data.List) == 0 { + return nil, fmt.Errorf("wopan download url: empty response") + } + return &drives.StreamLink{ + URL: data.List[0].DownloadUrl, + Headers: http.Header{}, + Expires: time.Now().Add(10 * time.Minute), + }, nil +} + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + // wopan SDK 要求 *os.File,先把流落到临时文件再上传 + tmp, err := os.CreateTemp("", "wopan-upload-*.tmp") + if err != nil { + return "", err + } + defer func() { + tmp.Close() + os.Remove(tmp.Name()) + }() + if _, err := io.Copy(tmp, r); err != nil { + return "", err + } + if _, err := tmp.Seek(0, 0); err != nil { + return "", err + } + fid, err := d.client.Upload2C(d.spaceType(), sdk.Upload2CFile{ + Name: name, + Size: size, + Content: tmp, + ContentType: "application/octet-stream", + }, parentID, d.familyID, sdk.Upload2COption{Ctx: ctx}) + if err != nil { + return "", fmt.Errorf("wopan upload: %w", err) + } + return fid, nil +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + parts := splitPath(pathFromRoot) + currentID := d.rootID + for _, name := range parts { + childID, err := d.findChildDir(ctx, currentID, name) + if err != nil { + return "", err + } + if childID == "" { + resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID) + if err != nil { + return "", fmt.Errorf("wopan mkdir %s: %w", name, err) + } + childID = resp.Id + } + currentID = childID + } + return currentID, nil +} + +func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) { + entries, err := d.List(ctx, parent) + if err != nil { + return "", err + } + for _, e := range entries { + if e.IsDir && e.Name == name { + return e.ID, nil + } + } + return "", nil +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +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.Fid + if id == "" { + id = f.Id + } + if isDir && !strings.HasSuffix(name, "/") { + // 不改 name,只标志 + } + return drives.Entry{ + ID: id, + Name: name, + Size: f.Size, + IsDir: isDir, + ParentID: parentID, + MimeType: guessMime(name), + ModTime: mod, + } +} + +func guessMime(name string) string { + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + } + return "application/octet-stream" +} + +// 确保实现接口 +var _ drives.Drive = (*Driver)(nil) diff --git a/backend/internal/fixedtags/tags.go b/backend/internal/fixedtags/tags.go new file mode 100644 index 0000000..57ab556 --- /dev/null +++ b/backend/internal/fixedtags/tags.go @@ -0,0 +1,100 @@ +package fixedtags + +import ( + "strings" + "unicode" +) + +var Labels = []string{"后入", "奶子", "口交", "臀", "人妻", "女大"} + +var aliases = map[string][]string{ + "后入": {"后入", "後入", "后入式", "後入式", "后进", "後進", "后位", "後位", "背入", "背后", "后背", "背后式", "后背位", "狗爬", "狗爬式", "追尾", "doggy", "doggystyle", "doggy style", "doggy-style", "backshot", "back shot", "back-shot", "from behind", "rear entry"}, + "奶子": {"奶子", "奶", "大奶", "巨乳", "美乳", "爆乳", "丰乳", "丰胸", "大胸", "胸", "胸部", "胸器", "胸前", "揉胸", "揉奶", "揉乳", "双乳", "乳房", "乳头", "美胸", "boob", "boobs", "big boobs", "big-boobs", "tits", "titties", "titty", "breast", "breasts"}, + "口交": {"口交", "口爆", "口活", "口射", "吹箫", "吹萧", "深喉", "吞精", "含屌", "含鸡巴", "含龟头", "舔屌", "bj", "blowjob", "blow job", "oral", "oral sex", "oral-sex", "oralsex", "fellatio"}, + "臀": {"臀", "屁股", "屁屁", "翘臀", "美臀", "肥臀", "巨臀", "蜜桃臀", "大屁股", "尻", "后庭", "後庭", "菊花", "肛", "肛交", "屁眼", "ass", "big ass", "big-ass", "butt", "big butt", "big-butt", "booty", "buttocks", "hip"}, + "人妻": {"人妻", "妻子", "老婆", "太太", "少妇", "少熟", "熟女", "已婚", "良家", "人妇", "人夫", "wife", "housewife", "married", "married woman", "young wife", "milf"}, + "女大": {"女大", "女大学生", "大学生", "女子大生", "大学", "女学生", "学生妹", "校花", "学妹", "校园", "大一", "大二", "大三", "大四", "college", "college student", "university", "university student", "campus", "coed"}, +} + +func MatchFilename(name string) []string { + text := normalize(name) + out := make([]string, 0, len(Labels)) + for _, label := range Labels { + for _, alias := range aliases[label] { + if text.contains(alias) { + out = append(out, label) + break + } + } + } + return out +} + +type normalizedText struct { + lower string + compact string + tokens map[string]struct{} +} + +func normalize(s string) normalizedText { + lower := strings.ToLower(s) + var compact strings.Builder + var spaced strings.Builder + for _, r := range lower { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + compact.WriteRune(r) + spaced.WriteRune(r) + continue + } + spaced.WriteByte(' ') + } + + tokens := make(map[string]struct{}) + for _, token := range strings.Fields(spaced.String()) { + tokens[token] = struct{}{} + } + + return normalizedText{ + lower: lower, + compact: compact.String(), + tokens: tokens, + } +} + +func (n normalizedText) contains(alias string) bool { + lowerAlias := strings.ToLower(alias) + compactAlias := compact(lowerAlias) + if compactAlias == "" { + return false + } + if isShortASCIIWord(compactAlias) && compactAlias == lowerAlias { + _, ok := n.tokens[compactAlias] + return ok + } + if strings.Contains(n.lower, lowerAlias) { + return true + } + return strings.Contains(n.compact, compactAlias) +} + +func compact(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func isShortASCIIWord(s string) bool { + if len(s) > 3 { + return false + } + for _, r := range s { + if r > unicode.MaxASCII || (!unicode.IsLetter(r) && !unicode.IsDigit(r)) { + return false + } + } + return true +} diff --git a/backend/internal/fixedtags/tags_test.go b/backend/internal/fixedtags/tags_test.go new file mode 100644 index 0000000..b9bb4d1 --- /dev/null +++ b/backend/internal/fixedtags/tags_test.go @@ -0,0 +1,33 @@ +package fixedtags + +import "testing" + +func TestMatchFilenameMapsSimilarTermsToFixedLabels(t *testing.T) { + got := MatchFilename("back-shot oral-sex big boobs big ass wife college student.mp4") + want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"} + + if !sameStrings(got, want) { + t.Fatalf("tags = %#v, want %#v", got, want) + } +} + +func TestMatchFilenameMapsChineseSimilarTermsToFixedLabels(t *testing.T) { + got := MatchFilename("背后式揉乳口活蜜桃臀少妇大学.mp4") + want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"} + + if !sameStrings(got, want) { + t.Fatalf("tags = %#v, want %#v", got, want) + } +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index 0737650..52d2bb0 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -1,493 +1,562 @@ -package preview - -import ( - "context" - "fmt" - "io" - "log" - "math" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/video-site/backend/internal/catalog" - "github.com/video-site/backend/internal/drives" -) - -type Config struct { - FFmpegPath string - FFprobePath string - DurationSeconds int // 单段时长(秒),用于单段 fallback;拼接模式下每段 = DurationSeconds / 段数 - Width int - Segments int // teaser 段数,1=单段,推荐 3 - LocalDir string // 本地兜底 - RemoteDir string // 远端目录路径(相对盘根) -} - -type Generator struct { - cfg Config -} - -func New(cfg Config) *Generator { - if cfg.FFmpegPath == "" { - cfg.FFmpegPath = "ffmpeg" - } - if cfg.FFprobePath == "" { - cfg.FFprobePath = "ffprobe" - } - if cfg.DurationSeconds == 0 { - cfg.DurationSeconds = 9 // 3 段 × 3 秒 - } - if cfg.Width == 0 { - cfg.Width = 480 - } - if cfg.Segments <= 0 { - cfg.Segments = 3 - } - return &Generator{cfg: cfg} -} - -// --- 选段策略 --- - -// pickSegmentStarts 根据视频总时长选出 N 段起点秒数(按时间升序) -// -// 规则: -// - duration < 30s → 单段从 max(2, duration*0.1) 起 -// - 30s ≤ duration < 10min → N 段:前段跳过片头、末段避开片尾 -// - duration ≥ 10min → 20% / 50% / 80%(或按 N 等距分布) -func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 { - if n <= 0 { - n = 1 - } - if duration <= 0 { - // 未知时长,用保守默认 - return []float64{10} - } - // 余量:保证最后一段结束前留 1 秒,避免切到文件末尾 - usable := duration - eachSec - 1 - if usable < 0 { - usable = 0 - } - - if duration < 30 { - start := math.Max(2, duration*0.1) - if start > usable { - start = math.Max(0, usable) - } - return []float64{start} - } - - if duration < 600 { - // 30s ~ 10min:20% 起,均匀分段 - starts := make([]float64, 0, n) - // 保证第一段跳过片头(>= 5% 或 3s) - firstMin := math.Max(3, duration*0.05) - // 最后一段结束 <= 85%,避开结尾 - lastMax := duration * 0.85 - if lastMax < firstMin { - lastMax = firstMin - } - if n == 1 { - return []float64{duration * 0.25} - } - step := (lastMax - firstMin) / float64(n-1) - for i := 0; i < n; i++ { - s := firstMin + step*float64(i) - if s > usable { - s = usable - } - starts = append(starts, s) - } - return starts - } - - // 长视频:按 20% / 50% / 80% 布置 - if n == 1 { - return []float64{duration * 0.3} - } - starts := make([]float64, 0, n) - pct := make([]float64, 0, n) - // 均匀在 [0.2, 0.8] 区间取 N 个点 - lo, hi := 0.2, 0.8 - if n == 1 { - pct = append(pct, 0.3) - } else { - step := (hi - lo) / float64(n-1) - for i := 0; i < n; i++ { - pct = append(pct, lo+step*float64(i)) - } - } - for _, p := range pct { - s := duration * p - if s > usable { - s = usable - } - starts = append(starts, s) - } - return starts -} - -// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。 -func pickThumbnailOffset(duration float64) float64 { - if duration <= 0 { - return 5 - } - // 短视频从 30% 抽;长视频从 20% 抽,避开片头 - if duration < 60 { - return math.Max(1, duration*0.3) - } - return math.Max(5, math.Min(duration*0.2, 120)) -} - -// --- 封面 --- - -// GenerateThumbnail 抽一张 jpg 封面。偏移点由 duration 决定(独立于 teaser)。 -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 := filepath.Join(dir, videoID+".jpg") - offset := pickThumbnailOffset(duration) - - ctx2, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - args := []string{ - "-hide_banner", - "-loglevel", "error", - "-ss", fmt.Sprintf("%.2f", offset), - } - if h := buildHeaders(link.Headers); h != "" { - args = append(args, "-headers", h) - } - args = append(args, - "-i", link.URL, - "-frames:v", "1", - "-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width), - "-q:v", "3", - "-y", dst, - ) - - cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...) - out, err := cmd.CombinedOutput() - if err != nil { - os.Remove(dst) - return "", fmt.Errorf("ffmpeg thumb: %w, stderr: %s", err, string(out)) - } - if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 { - os.Remove(dst) - return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out)) - } - return dst, nil -} - -// --- 时长 --- - -// Probe 用 ffprobe 拿视频时长(秒,浮点) -func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64, error) { - ctx2, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - args := []string{ - "-hide_banner", - "-loglevel", "error", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - } - if h := buildHeaders(link.Headers); h != "" { - args = append(args, "-headers", h) - } - args = append(args, link.URL) - - cmd := exec.CommandContext(ctx2, g.cfg.FFprobePath, args...) - out, err := cmd.Output() - if err != nil { - return 0, fmt.Errorf("ffprobe: %w", err) - } - raw := strings.TrimSpace(string(out)) - if raw == "" || raw == "N/A" { - return 0, nil - } - return strconv.ParseFloat(raw, 64) -} - -// --- Teaser --- - -// Generate 拉取 teaser 到本地临时文件,返回路径。 -// 根据 Config.Segments 和视频时长决定是单段还是多段拼接。 -func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) { - if err := os.MkdirAll(g.cfg.LocalDir, 0o755); err != nil { - return "", err - } - - segs := g.cfg.Segments - // 视频太短直接单段 - if duration > 0 && duration < 30 { - segs = 1 - } - - eachSec := float64(g.cfg.DurationSeconds) - if segs > 1 { - eachSec = float64(g.cfg.DurationSeconds) / float64(segs) - if eachSec < 2 { - eachSec = 2 - } - } - - starts := pickSegmentStarts(duration, segs, eachSec) - - ctx2, cancel := context.WithTimeout(ctx, 4*time.Minute) - defer cancel() - - // 用 ffmpeg 的 concat 滤镜一次输出:多个 -ss input 再 concat + fade - tmp, err := os.CreateTemp(g.cfg.LocalDir, "teaser-*.mp4") - if err != nil { - return "", err - } - tmpPath := tmp.Name() - tmp.Close() - - args := []string{ - "-hide_banner", - "-loglevel", "error", - } - headers := buildHeaders(link.Headers) - - // 每段独立 -ss + -i,精确 seek 重新解码保证拼接帧准 - for _, s := range starts { - if headers != "" { - args = append(args, "-headers", headers) - } - args = append(args, - "-ss", fmt.Sprintf("%.2f", s), - "-t", fmt.Sprintf("%.2f", eachSec), - "-i", link.URL, - ) - } - - if len(starts) == 1 { - // 单段:无需 concat,直接缩放 + 无音 - args = append(args, - "-an", - "-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width), - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "28", - "-movflags", "+faststart", - "-y", tmpPath, - ) - } else { - // 多段:各段缩放 + 0.2s 黑场淡入淡出,concat 拼接 - // filter_complex: [0:v]scale,fade=in:0:5,fade=out:start=eachSec-0.2:d=0.2[v0]; ...; [v0][v1][v2]concat=n=3:v=1:a=0[v] - fadeIn := 0.2 - fadeOutStart := eachSec - 0.2 - if fadeOutStart < 0 { - fadeOutStart = 0 - } - var filter strings.Builder - for i := range starts { - if i > 0 { - filter.WriteString(";") - } - fmt.Fprintf(&filter, - "[%d:v]scale=%d:-2,fade=t=in:st=0:d=%.2f,fade=t=out:st=%.2f:d=0.2[v%d]", - i, g.cfg.Width, fadeIn, fadeOutStart, i) - } - filter.WriteString(";") - for i := range starts { - fmt.Fprintf(&filter, "[v%d]", i) - } - fmt.Fprintf(&filter, "concat=n=%d:v=1:a=0[v]", len(starts)) - - args = append(args, - "-filter_complex", filter.String(), - "-map", "[v]", - "-an", - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "28", - "-movflags", "+faststart", - "-y", tmpPath, - ) - } - - cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...) - out, err := cmd.CombinedOutput() - if err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("ffmpeg: %w, stderr: %s", err, string(out)) - } - - if info, statErr := os.Stat(tmpPath); statErr != nil || info.Size() == 0 { - os.Remove(tmpPath) - return "", fmt.Errorf("ffmpeg produced empty file, stderr: %s", string(out)) - } - return tmpPath, nil -} - -// --- 本地落盘 --- - -// MoveToLocal 把临时文件改名到稳定位置,返回最终路径 -func (g *Generator) MoveToLocal(tmpPath, videoID string) (string, error) { - 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 { - return "", cerr - } - _ = os.Remove(tmpPath) - } - return dst, nil -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, in) - return err -} - -// --- Worker --- - -type Worker struct { - Gen *Generator - Catalog *catalog.Catalog - Drive drives.Drive - RemoteDir string - ch chan *catalog.Video -} - -func NewWorker(gen *Generator, cat *catalog.Catalog, drv drives.Drive, remoteDir string) *Worker { - return &Worker{ - Gen: gen, - Catalog: cat, - Drive: drv, - RemoteDir: remoteDir, - ch: make(chan *catalog.Video, 4096), - } -} - -func (w *Worker) Enqueue(v *catalog.Video) { - select { - case w.ch <- v: - default: - } -} - -// Run 阻塞运行直到 ctx 取消 -func (w *Worker) Run(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case v := <-w.ch: - w.process(ctx, v) - select { - case <-ctx.Done(): - return - case <-time.After(500 * time.Millisecond): - } - } - } -} - -func (w *Worker) process(ctx context.Context, v *catalog.Video) { - link, err := w.Drive.StreamURL(ctx, v.FileID) - if err != nil { - log.Printf("[preview] streamURL %s: %v", v.Title, err) - w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") - return - } - - // 1) 探时长(失败用 0 继续) - duration := float64(v.DurationSeconds) - if duration <= 0 { - if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 { - duration = dur - _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ - DurationSeconds: int(dur), - }) - } - } - - // 2) 封面(独立时间点,失败不致命) - if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil { - log.Printf("[preview] thumbnail %s: %v", v.Title, err) - } else { - _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ - ThumbnailURL: "/p/thumb/" + v.ID, - }) - } - - // 3) teaser - tmp, err := w.Gen.Generate(ctx, link, duration) - if err != nil { - log.Printf("[preview] generate %s: %v", v.Title, err) - w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") - return - } - local, err := w.Gen.MoveToLocal(tmp, v.ID) - if err != nil { - log.Printf("[preview] move %s: %v", v.Title, err) - w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") - return - } - - previewFileID := "" - if w.RemoteDir != "" { - if fid, uerr := w.uploadToDrive(ctx, v.ID, local); uerr == nil { - previewFileID = fid - } else { - log.Printf("[preview] upload %s: %v (local only)", v.Title, uerr) - } - } - w.Catalog.UpdatePreview(ctx, v.ID, previewFileID, local, "ready") - log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration) -} - -func (w *Worker) uploadToDrive(ctx context.Context, videoID, localPath string) (string, error) { - parentID, err := w.Drive.EnsureDir(ctx, w.RemoteDir) - if err != nil { - return "", err - } - f, err := os.Open(localPath) - if err != nil { - return "", err - } - defer f.Close() - stat, err := f.Stat() - if err != nil { - return "", err - } - return w.Drive.Upload(ctx, parentID, videoID+".mp4", f, stat.Size()) -} - -// --- utils --- - -func buildHeaders(h map[string][]string) string { - if len(h) == 0 { - return "" - } - var sb strings.Builder - for k, vs := range h { - for _, v := range vs { - sb.WriteString(k) - sb.WriteString(": ") - sb.WriteString(v) - sb.WriteString("\r\n") - } - } - return sb.String() -} +package preview + +import ( + "context" + "fmt" + "io" + "log" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives" +) + +type Config struct { + FFmpegPath string + FFprobePath string + DurationSeconds int // 单段时长(秒),用于单段 fallback;拼接模式下每段 = DurationSeconds / 段数 + Width int + Segments int // teaser 段数,1=单段,推荐 3 + LocalDir string // 本地兜底 + RemoteDir string // 远端目录路径(相对盘根) +} + +type Generator struct { + cfg Config +} + +type ThumbnailGenerator interface { + Probe(ctx context.Context, link *drives.StreamLink) (float64, error) + GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) +} + +type TeaserGenerator interface { + Probe(ctx context.Context, link *drives.StreamLink) (float64, error) + Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) + MoveToLocal(tmpPath, videoID string) (string, error) +} + +func New(cfg Config) *Generator { + if cfg.FFmpegPath == "" { + cfg.FFmpegPath = "ffmpeg" + } + if cfg.FFprobePath == "" { + cfg.FFprobePath = "ffprobe" + } + if cfg.DurationSeconds == 0 { + cfg.DurationSeconds = 9 // 3 段 × 3 秒 + } + if cfg.Width == 0 { + cfg.Width = 480 + } + if cfg.Segments <= 0 { + cfg.Segments = 3 + } + return &Generator{cfg: cfg} +} + +// --- 选段策略 --- + +// pickSegmentStarts 根据视频总时长选出 N 段起点秒数(按时间升序) +// +// 规则: +// - duration < 30s → 单段从 max(2, duration*0.1) 起 +// - 30s ≤ duration < 10min → N 段:前段跳过片头、末段避开片尾 +// - duration ≥ 10min → 20% / 50% / 80%(或按 N 等距分布) +func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 { + if n <= 0 { + n = 1 + } + if duration <= 0 { + // 未知时长,用保守默认 + return []float64{10} + } + // 余量:保证最后一段结束前留 1 秒,避免切到文件末尾 + usable := duration - eachSec - 1 + if usable < 0 { + usable = 0 + } + + if duration < 30 { + start := math.Max(2, duration*0.1) + if start > usable { + start = math.Max(0, usable) + } + return []float64{start} + } + + if duration < 600 { + // 30s ~ 10min:20% 起,均匀分段 + starts := make([]float64, 0, n) + // 保证第一段跳过片头(>= 5% 或 3s) + firstMin := math.Max(3, duration*0.05) + // 最后一段结束 <= 85%,避开结尾 + lastMax := duration * 0.85 + if lastMax < firstMin { + lastMax = firstMin + } + if n == 1 { + return []float64{duration * 0.25} + } + step := (lastMax - firstMin) / float64(n-1) + for i := 0; i < n; i++ { + s := firstMin + step*float64(i) + if s > usable { + s = usable + } + starts = append(starts, s) + } + return starts + } + + // 长视频:按 20% / 50% / 80% 布置 + if n == 1 { + return []float64{duration * 0.3} + } + starts := make([]float64, 0, n) + pct := make([]float64, 0, n) + // 均匀在 [0.2, 0.8] 区间取 N 个点 + lo, hi := 0.2, 0.8 + if n == 1 { + pct = append(pct, 0.3) + } else { + step := (hi - lo) / float64(n-1) + for i := 0; i < n; i++ { + pct = append(pct, lo+step*float64(i)) + } + } + for _, p := range pct { + s := duration * p + if s > usable { + s = usable + } + starts = append(starts, s) + } + return starts +} + +// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。 +func pickThumbnailOffset(duration float64) float64 { + if duration <= 0 { + return 5 + } + // 短视频从 30% 抽;长视频从 20% 抽,避开片头 + if duration < 60 { + return math.Max(1, duration*0.3) + } + return math.Max(5, math.Min(duration*0.2, 120)) +} + +// --- 封面 --- + +// GenerateThumbnail 抽一张 jpg 封面。偏移点由 duration 决定(独立于 teaser)。 +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 := filepath.Join(dir, videoID+".jpg") + offset := pickThumbnailOffset(duration) + + ctx2, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-ss", fmt.Sprintf("%.2f", offset), + } + if h := buildHeaders(link.Headers); h != "" { + args = append(args, "-headers", h) + } + args = append(args, + "-i", link.URL, + "-frames:v", "1", + "-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width), + "-q:v", "3", + "-y", dst, + ) + + cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...) + out, err := cmd.CombinedOutput() + if err != nil { + os.Remove(dst) + return "", fmt.Errorf("ffmpeg thumb: %w, stderr: %s", err, string(out)) + } + if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 { + os.Remove(dst) + return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out)) + } + return dst, nil +} + +// --- 时长 --- + +// Probe 用 ffprobe 拿视频时长(秒,浮点) +func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64, error) { + ctx2, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + } + if h := buildHeaders(link.Headers); h != "" { + args = append(args, "-headers", h) + } + args = append(args, link.URL) + + cmd := exec.CommandContext(ctx2, g.cfg.FFprobePath, args...) + out, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("ffprobe: %w", err) + } + raw := strings.TrimSpace(string(out)) + if raw == "" || raw == "N/A" { + return 0, nil + } + return strconv.ParseFloat(raw, 64) +} + +// --- Teaser --- + +// Generate 拉取 teaser 到本地临时文件,返回路径。 +// 根据 Config.Segments 和视频时长决定是单段还是多段拼接。 +func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) { + if err := os.MkdirAll(g.cfg.LocalDir, 0o755); err != nil { + return "", err + } + + segs := g.cfg.Segments + // 视频太短直接单段 + if duration > 0 && duration < 30 { + segs = 1 + } + + eachSec := float64(g.cfg.DurationSeconds) + if segs > 1 { + eachSec = float64(g.cfg.DurationSeconds) / float64(segs) + if eachSec < 2 { + eachSec = 2 + } + } + + starts := pickSegmentStarts(duration, segs, eachSec) + + ctx2, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + + // 用 ffmpeg 的 concat 滤镜一次输出:多个 -ss input 再 concat + fade + tmp, err := os.CreateTemp(g.cfg.LocalDir, "teaser-*.mp4") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + tmp.Close() + + args := []string{ + "-hide_banner", + "-loglevel", "error", + } + headers := buildHeaders(link.Headers) + + // 每段独立 -ss + -i,精确 seek 重新解码保证拼接帧准 + for _, s := range starts { + if headers != "" { + args = append(args, "-headers", headers) + } + args = append(args, + "-ss", fmt.Sprintf("%.2f", s), + "-t", fmt.Sprintf("%.2f", eachSec), + "-i", link.URL, + ) + } + + if len(starts) == 1 { + // 单段:无需 concat,直接缩放 + 无音 + args = append(args, + "-an", + "-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width), + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "28", + "-movflags", "+faststart", + "-y", tmpPath, + ) + } else { + // 多段:各段缩放 + 0.2s 黑场淡入淡出,concat 拼接 + // filter_complex: [0:v]scale,fade=in:0:5,fade=out:start=eachSec-0.2:d=0.2[v0]; ...; [v0][v1][v2]concat=n=3:v=1:a=0[v] + fadeIn := 0.2 + fadeOutStart := eachSec - 0.2 + if fadeOutStart < 0 { + fadeOutStart = 0 + } + var filter strings.Builder + for i := range starts { + if i > 0 { + filter.WriteString(";") + } + fmt.Fprintf(&filter, + "[%d:v]scale=%d:-2,fade=t=in:st=0:d=%.2f,fade=t=out:st=%.2f:d=0.2[v%d]", + i, g.cfg.Width, fadeIn, fadeOutStart, i) + } + filter.WriteString(";") + for i := range starts { + fmt.Fprintf(&filter, "[v%d]", i) + } + fmt.Fprintf(&filter, "concat=n=%d:v=1:a=0[v]", len(starts)) + + args = append(args, + "-filter_complex", filter.String(), + "-map", "[v]", + "-an", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "28", + "-movflags", "+faststart", + "-y", tmpPath, + ) + } + + cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...) + out, err := cmd.CombinedOutput() + if err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("ffmpeg: %w, stderr: %s", err, string(out)) + } + + if info, statErr := os.Stat(tmpPath); statErr != nil || info.Size() == 0 { + os.Remove(tmpPath) + return "", fmt.Errorf("ffmpeg produced empty file, stderr: %s", string(out)) + } + return tmpPath, nil +} + +// --- 本地落盘 --- + +// MoveToLocal 把临时文件改名到稳定位置,返回最终路径 +func (g *Generator) MoveToLocal(tmpPath, videoID string) (string, error) { + 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 { + return "", cerr + } + _ = os.Remove(tmpPath) + } + return dst, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// --- Worker --- + +type Worker struct { + Gen TeaserGenerator + Catalog *catalog.Catalog + Drive drives.Drive + RemoteDir string + ch chan *catalog.Video +} + +func NewWorker(gen TeaserGenerator, cat *catalog.Catalog, drv drives.Drive, remoteDir string) *Worker { + return &Worker{ + Gen: gen, + Catalog: cat, + Drive: drv, + RemoteDir: remoteDir, + ch: make(chan *catalog.Video, 4096), + } +} + +func (w *Worker) Enqueue(v *catalog.Video) { + select { + case w.ch <- v: + default: + } +} + +type ThumbWorker struct { + Gen ThumbnailGenerator + Catalog *catalog.Catalog + Drive drives.Drive + ch chan *catalog.Video +} + +func NewThumbWorker(gen ThumbnailGenerator, cat *catalog.Catalog, drv drives.Drive) *ThumbWorker { + return &ThumbWorker{ + Gen: gen, + Catalog: cat, + Drive: drv, + ch: make(chan *catalog.Video, 4096), + } +} + +func (w *ThumbWorker) Enqueue(v *catalog.Video) { + select { + case w.ch <- v: + default: + } +} + +// Run 阻塞运行直到 ctx 取消 +func (w *Worker) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case v := <-w.ch: + w.process(ctx, v) + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } + } +} + +// Run 阻塞运行直到 ctx 取消 +func (w *ThumbWorker) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case v := <-w.ch: + w.process(ctx, v) + select { + case <-ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } + } + } +} + +func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) { + link, err := w.Drive.StreamURL(ctx, v.FileID) + if err != nil { + log.Printf("[thumb] streamURL %s: %v", v.Title, err) + return + } + + duration := float64(v.DurationSeconds) + if duration <= 0 { + if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 { + duration = dur + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ + DurationSeconds: int(dur), + }) + } + } + + if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil { + log.Printf("[thumb] generate %s: %v", v.Title, err) + return + } + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ + ThumbnailURL: "/p/thumb/" + v.ID, + }) + log.Printf("[thumb] ready %s", v.Title) +} + +func (w *Worker) process(ctx context.Context, v *catalog.Video) { + link, err := w.Drive.StreamURL(ctx, v.FileID) + if err != nil { + log.Printf("[preview] streamURL %s: %v", v.Title, err) + w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") + return + } + + // 1) 探时长(失败用 0 继续) + duration := float64(v.DurationSeconds) + if duration <= 0 { + if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 { + duration = dur + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ + DurationSeconds: int(dur), + }) + } + } + + // 2) teaser + tmp, err := w.Gen.Generate(ctx, link, duration) + if err != nil { + log.Printf("[preview] generate %s: %v", v.Title, err) + w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") + return + } + local, err := w.Gen.MoveToLocal(tmp, v.ID) + if err != nil { + log.Printf("[preview] move %s: %v", v.Title, err) + w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed") + return + } + + previewFileID := "" + if w.RemoteDir != "" { + if fid, uerr := w.uploadToDrive(ctx, v.ID, local); uerr == nil { + previewFileID = fid + } else { + log.Printf("[preview] upload %s: %v (local only)", v.Title, uerr) + } + } + w.Catalog.UpdatePreview(ctx, v.ID, previewFileID, local, "ready") + log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration) +} + +func (w *Worker) uploadToDrive(ctx context.Context, videoID, localPath string) (string, error) { + parentID, err := w.Drive.EnsureDir(ctx, w.RemoteDir) + if err != nil { + return "", err + } + f, err := os.Open(localPath) + if err != nil { + return "", err + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return "", err + } + return w.Drive.Upload(ctx, parentID, videoID+".mp4", f, stat.Size()) +} + +// --- utils --- + +func buildHeaders(h map[string][]string) string { + if len(h) == 0 { + return "" + } + var sb strings.Builder + for k, vs := range h { + for _, v := range vs { + sb.WriteString(k) + sb.WriteString(": ") + sb.WriteString(v) + sb.WriteString("\r\n") + } + } + return sb.String() +} diff --git a/backend/internal/preview/worker_test.go b/backend/internal/preview/worker_test.go new file mode 100644 index 0000000..de0df6f --- /dev/null +++ b/backend/internal/preview/worker_test.go @@ -0,0 +1,159 @@ +package preview + +import ( + "context" + "io" + "testing" + "time" + + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives" +) + +func TestThumbWorkerUpdatesThumbnailWithoutChangingPreviewStatus(t *testing.T) { + ctx := context.Background() + cat, video := seedPreviewTestVideo(t, "thumb-worker-video") + + gen := &fakeThumbGenerator{} + drv := &previewFakeDrive{} + 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 != "/p/thumb/"+video.ID { + t.Fatalf("thumbnail = %q, want generated thumb URL", got.ThumbnailURL) + } + if got.PreviewStatus != "pending" { + t.Fatalf("preview status = %q, want pending", got.PreviewStatus) + } + if got.DurationSeconds != 42 { + t.Fatalf("duration = %d, want probed duration", got.DurationSeconds) + } + 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 42", gen.thumbnailDuration) + } + if drv.streamFileID != video.FileID { + t.Fatalf("stream file id = %q, want %q", drv.streamFileID, video.FileID) + } +} + +func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testing.T) { + ctx := context.Background() + cat, video := seedPreviewTestVideo(t, "preview-worker-video") + video.ThumbnailURL = "https://thumbnail.example/original.jpg" + if err := cat.UpsertVideo(ctx, video); err != nil { + t.Fatalf("update video: %v", err) + } + + gen := &fakeTeaserGenerator{} + drv := &previewFakeDrive{} + worker := NewWorker(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 != "https://thumbnail.example/original.jpg" { + t.Fatalf("thumbnail = %q, want existing thumbnail unchanged", got.ThumbnailURL) + } + if got.PreviewStatus != "ready" { + t.Fatalf("preview status = %q, want ready", got.PreviewStatus) + } + if got.PreviewLocal != "/tmp/"+video.ID+".mp4" { + t.Fatalf("preview local = %q, want moved teaser path", got.PreviewLocal) + } +} + +func seedPreviewTestVideo(t *testing.T, id string) (*catalog.Catalog, *catalog.Video) { + t.Helper() + 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) + } + }) + + video := &catalog.Video{ + ID: id, + DriveID: "drive-id", + FileID: "file-id", + Title: "Clip", + PreviewStatus: "pending", + PublishedAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := cat.UpsertVideo(ctx, video); err != nil { + t.Fatalf("seed video: %v", err) + } + return cat, video +} + +type fakeThumbGenerator struct { + thumbnailVideoID string + thumbnailDuration float64 +} + +func (g *fakeThumbGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) { + return 42, nil +} + +func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, _ *drives.StreamLink, videoID string, duration float64) (string, error) { + g.thumbnailVideoID = videoID + g.thumbnailDuration = duration + return "/tmp/" + videoID + ".jpg", nil +} + +type fakeTeaserGenerator struct{} + +func (g *fakeTeaserGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) { + return 0, nil +} + +func (g *fakeTeaserGenerator) Generate(context.Context, *drives.StreamLink, float64) (string, error) { + return "/tmp/source-teaser.mp4", nil +} + +func (g *fakeTeaserGenerator) MoveToLocal(_ string, videoID string) (string, error) { + return "/tmp/" + videoID + ".mp4", nil +} + +type previewFakeDrive struct { + streamFileID string +} + +func (d *previewFakeDrive) Kind() string { return "fake" } +func (d *previewFakeDrive) ID() string { return "drive-id" } +func (d *previewFakeDrive) Init(context.Context) error { + return nil +} +func (d *previewFakeDrive) List(context.Context, string) ([]drives.Entry, error) { + return nil, nil +} +func (d *previewFakeDrive) Stat(context.Context, string) (*drives.Entry, error) { + return nil, drives.ErrNotSupported +} +func (d *previewFakeDrive) StreamURL(_ context.Context, fileID string) (*drives.StreamLink, error) { + d.streamFileID = fileID + return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil +} +func (d *previewFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) { + return "", drives.ErrNotSupported +} +func (d *previewFakeDrive) EnsureDir(context.Context, string) (string, error) { + return "", drives.ErrNotSupported +} +func (d *previewFakeDrive) RootID() string { return "root" } diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go index a1c0b19..86979fc 100644 --- a/backend/internal/proxy/proxy.go +++ b/backend/internal/proxy/proxy.go @@ -1,169 +1,169 @@ -package proxy - -import ( - "context" - "io" - "net/http" - "net/url" - "sync" - "time" - - "github.com/video-site/backend/internal/drives" -) - -// Registry 管理多个 Drive 实例 -type Registry struct { - mu sync.RWMutex - drives map[string]drives.Drive -} - -func NewRegistry() *Registry { - return &Registry{drives: make(map[string]drives.Drive)} -} - -func (r *Registry) Set(id string, d drives.Drive) { - r.mu.Lock() - defer r.mu.Unlock() - r.drives[id] = d -} - -func (r *Registry) Get(id string) (drives.Drive, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - d, ok := r.drives[id] - return d, ok -} - -func (r *Registry) All() []drives.Drive { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]drives.Drive, 0, len(r.drives)) - for _, d := range r.drives { - out = append(out, d) - } - return out -} - -func (r *Registry) Remove(id string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.drives, id) -} - -// Proxy 根据 driveID + fileID 反向代理到真实网盘直链 -type Proxy struct { - Registry *Registry - // linkCache key: driveID + "/" + fileID,value: cachedLink - cacheMu sync.Mutex - cache map[string]cachedLink - http *http.Client -} - -type cachedLink struct { - link *drives.StreamLink - fetched time.Time -} - -func New(r *Registry) *Proxy { - return &Proxy{ - Registry: r, - cache: make(map[string]cachedLink), - http: &http.Client{ - Timeout: 0, // 流式不设超时 - }, - } -} - -func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) { - key := driveID + "/" + fileID - - p.cacheMu.Lock() - if c, ok := p.cache[key]; ok { - // 缓存 30 秒,且不超过 link.Expires - if time.Since(c.fetched) < 30*time.Second && time.Now().Before(c.link.Expires) { - p.cacheMu.Unlock() - return c.link, nil - } - } - p.cacheMu.Unlock() - - d, ok := p.Registry.Get(driveID) - if !ok { - return nil, errDriveNotFound - } - link, err := d.StreamURL(ctx, fileID) - if err != nil { - return nil, err - } - p.cacheMu.Lock() - p.cache[key] = cachedLink{link: link, fetched: time.Now()} - p.cacheMu.Unlock() - return link, nil -} - -func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) { - link, err := p.getLink(r.Context(), driveID, fileID) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - p.serve(w, r, link) -} - -func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) { - // 构造上游请求 - u, err := url.Parse(link.URL) - if err != nil { - http.Error(w, "bad upstream url", http.StatusBadGateway) - return - } - req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - // 复制上游请求头 - for k, vs := range link.Headers { - for _, v := range vs { - req.Header.Add(k, v) - } - } - // 透传 Range - if rng := r.Header.Get("Range"); rng != "" { - req.Header.Set("Range", rng) - } - - resp, err := p.http.Do(req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - // 透传响应头 - for _, k := range []string{ - "Content-Type", "Content-Length", "Content-Range", - "Accept-Ranges", "Last-Modified", "Etag", - } { - if v := resp.Header.Get(k); v != "" { - w.Header().Set(k, v) - } - } - w.Header().Set("Cache-Control", "private, max-age=300") - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) -} - -// ServeLocal 服务本地 teaser 文件 -func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) { - http.ServeFile(w, r, path) -} - -var errDriveNotFound = &httpError{Code: http.StatusNotFound, Msg: "drive not found"} - -type httpError struct { - Code int - Msg string -} - -func (e *httpError) Error() string { return e.Msg } +package proxy + +import ( + "context" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/video-site/backend/internal/drives" +) + +// Registry 管理多个 Drive 实例 +type Registry struct { + mu sync.RWMutex + drives map[string]drives.Drive +} + +func NewRegistry() *Registry { + return &Registry{drives: make(map[string]drives.Drive)} +} + +func (r *Registry) Set(id string, d drives.Drive) { + r.mu.Lock() + defer r.mu.Unlock() + r.drives[id] = d +} + +func (r *Registry) Get(id string) (drives.Drive, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + d, ok := r.drives[id] + return d, ok +} + +func (r *Registry) All() []drives.Drive { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]drives.Drive, 0, len(r.drives)) + for _, d := range r.drives { + out = append(out, d) + } + return out +} + +func (r *Registry) Remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.drives, id) +} + +// Proxy 根据 driveID + fileID 反向代理到真实网盘直链 +type Proxy struct { + Registry *Registry + // linkCache key: driveID + "/" + fileID,value: cachedLink + cacheMu sync.Mutex + cache map[string]cachedLink + http *http.Client +} + +type cachedLink struct { + link *drives.StreamLink + fetched time.Time +} + +func New(r *Registry) *Proxy { + return &Proxy{ + Registry: r, + cache: make(map[string]cachedLink), + http: &http.Client{ + Timeout: 0, // 流式不设超时 + }, + } +} + +func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) { + key := driveID + "/" + fileID + + p.cacheMu.Lock() + if c, ok := p.cache[key]; ok { + // 缓存 30 秒,且不超过 link.Expires + if time.Since(c.fetched) < 30*time.Second && time.Now().Before(c.link.Expires) { + p.cacheMu.Unlock() + return c.link, nil + } + } + p.cacheMu.Unlock() + + d, ok := p.Registry.Get(driveID) + if !ok { + return nil, errDriveNotFound + } + link, err := d.StreamURL(ctx, fileID) + if err != nil { + return nil, err + } + p.cacheMu.Lock() + p.cache[key] = cachedLink{link: link, fetched: time.Now()} + p.cacheMu.Unlock() + return link, nil +} + +func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) { + link, err := p.getLink(r.Context(), driveID, fileID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + p.serve(w, r, link) +} + +func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) { + // 构造上游请求 + u, err := url.Parse(link.URL) + if err != nil { + http.Error(w, "bad upstream url", http.StatusBadGateway) + return + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 复制上游请求头 + for k, vs := range link.Headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + // 透传 Range + if rng := r.Header.Get("Range"); rng != "" { + req.Header.Set("Range", rng) + } + + resp, err := p.http.Do(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // 透传响应头 + for _, k := range []string{ + "Content-Type", "Content-Length", "Content-Range", + "Accept-Ranges", "Last-Modified", "Etag", + } { + if v := resp.Header.Get(k); v != "" { + w.Header().Set(k, v) + } + } + w.Header().Set("Cache-Control", "private, max-age=300") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +// ServeLocal 服务本地 teaser 文件 +func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) { + http.ServeFile(w, r, path) +} + +var errDriveNotFound = &httpError{Code: http.StatusNotFound, Msg: "drive not found"} + +type httpError struct { + Code int + Msg string +} + +func (e *httpError) Error() string { return e.Msg } diff --git a/backend/internal/scanner/filename.go b/backend/internal/scanner/filename.go index c1f4572..6616ab7 100644 --- a/backend/internal/scanner/filename.go +++ b/backend/internal/scanner/filename.go @@ -1,49 +1,42 @@ -package scanner - -import ( - "path" - "regexp" - "strings" -) - -// ParsedName 从文件名里解析出的视频元数据 -type ParsedName struct { - Title string - Author string - Tags []string -} - -var ( - reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2] - reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author -) - -// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext -// 任何字段缺失都能降级 -func Parse(filename string) ParsedName { - name := strings.TrimSuffix(filename, path.Ext(filename)) - - var out ParsedName - - if m := reTags.FindStringSubmatch(name); m != nil { - raw := m[1] - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ',' || r == '、' || r == ' ' - }) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out.Tags = append(out.Tags, p) - } - } - name = strings.TrimSpace(name[len(m[0]):]) - } - - if m := reAuthor.FindStringSubmatch(name); m != nil { - out.Author = strings.TrimSpace(m[1]) - name = strings.TrimSpace(name[:len(name)-len(m[0])]) - } - - out.Title = strings.TrimSpace(name) - return out -} +package scanner + +import ( + "path" + "regexp" + "strings" + + "github.com/video-site/backend/internal/fixedtags" +) + +// ParsedName 从文件名里解析出的视频元数据 +type ParsedName struct { + Title string + Author string + Tags []string +} + +var ( + reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2] + reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author +) + +// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext +// 任何字段缺失都能降级 +func Parse(filename string) ParsedName { + name := strings.TrimSuffix(filename, path.Ext(filename)) + + var out ParsedName + + if m := reTags.FindStringSubmatch(name); m != nil { + name = strings.TrimSpace(name[len(m[0]):]) + } + + if m := reAuthor.FindStringSubmatch(name); m != nil { + out.Author = strings.TrimSpace(m[1]) + name = strings.TrimSpace(name[:len(name)-len(m[0])]) + } + + out.Title = strings.TrimSpace(name) + out.Tags = fixedtags.MatchFilename(filename) + return out +} diff --git a/backend/internal/scanner/filename_test.go b/backend/internal/scanner/filename_test.go new file mode 100644 index 0000000..1060e33 --- /dev/null +++ b/backend/internal/scanner/filename_test.go @@ -0,0 +1,32 @@ +package scanner + +import "testing" + +func TestParseMatchesOnlyFixedTagsFromFilename(t *testing.T) { + got := Parse("[乱七八糟] 女大人妻后入口交奶子臀.mp4") + want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"} + + if !sameStrings(got.Tags, want) { + t.Fatalf("tags = %#v, want %#v", got.Tags, want) + } +} + +func TestParseDoesNotKeepBracketTags(t *testing.T) { + got := Parse("[sunny,kenny] 普通标题.mp4") + + if len(got.Tags) != 0 { + t.Fatalf("tags = %#v, want none", got.Tags) + } +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/scanner/scanner.go b/backend/internal/scanner/scanner.go index 6c2a87f..3a9b192 100644 --- a/backend/internal/scanner/scanner.go +++ b/backend/internal/scanner/scanner.go @@ -1,139 +1,163 @@ -package scanner - -import ( - "context" - "fmt" - "log" - "path" - "strings" - "time" - - "github.com/video-site/backend/internal/catalog" - "github.com/video-site/backend/internal/drives" -) - -type Scanner struct { - Catalog *catalog.Catalog - Drive drives.Drive - Exts map[string]bool - MaxDepth int - // 回调:新视频被加入后触发 teaser 生成 - OnNewVideo func(v *catalog.Video) -} - -func New(cat *catalog.Catalog, drv drives.Drive, exts []string, maxDepth int, onNew func(v *catalog.Video)) *Scanner { - m := make(map[string]bool, len(exts)) - for _, e := range exts { - m[strings.ToLower(e)] = true - } - if maxDepth == 0 { - maxDepth = 5 - } - return &Scanner{ - Catalog: cat, - Drive: drv, - Exts: m, - MaxDepth: maxDepth, - OnNewVideo: onNew, - } -} - -type Stats struct { - Scanned int - Added int -} - -// Run 从 Drive.RootID 开始扫描 -func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) { - if startDirID == "" { - startDirID = s.Drive.RootID() - } - stats := Stats{} - if err := s.walk(ctx, startDirID, "", 0, &stats); err != nil { - return stats, err - } - return stats, nil -} - -func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, stats *Stats) error { - if depth >= s.MaxDepth { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - - entries, err := s.Drive.List(ctx, dirID) - if err != nil { - return fmt.Errorf("list %s: %w", dirID, err) - } - - for _, e := range entries { - if e.IsDir { - // 跳过 previews 目录,避免扫到自己生成的 teaser - if strings.EqualFold(e.Name, "previews") { - continue - } - if err := s.walk(ctx, e.ID, e.Name, depth+1, stats); err != nil { - 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 - } - - id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID - existing, _ := s.Catalog.GetVideo(ctx, id) - if existing != nil { - // 已存在但 category 空缺时,顺便补 category - if existing.Category == "" && dirName != "" { - _ = s.Catalog.UpdateVideoMeta(ctx, id, catalog.VideoMetaPatch{Category: dirName}) - } - continue - } - - parsed := Parse(e.Name) - if parsed.Title == "" { - parsed.Title = strings.TrimSuffix(e.Name, ext) - } - - now := time.Now() - v := &catalog.Video{ - ID: id, - DriveID: s.Drive.ID(), - FileID: e.ID, - ParentID: e.ParentID, - Title: parsed.Title, - Author: parsed.Author, - Tags: parsed.Tags, - Ext: strings.TrimPrefix(ext, "."), - Quality: "HD", - Size: e.Size, - PreviewStatus: "pending", - Category: dirName, - PublishedAt: orDefault(e.ModTime, now), - CreatedAt: now, - UpdatedAt: now, - } - if err := s.Catalog.UpsertVideo(ctx, v); err != nil { - log.Printf("[scanner] upsert %s error: %v", v.Title, err) - continue - } - stats.Added++ - if s.OnNewVideo != nil { - s.OnNewVideo(v) - } - } - return nil -} - -func orDefault(t time.Time, d time.Time) time.Time { - if t.IsZero() { - return d - } - return t -} +package scanner + +import ( + "context" + "fmt" + "log" + "path" + "strings" + "time" + + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives" +) + +type Scanner struct { + Catalog *catalog.Catalog + Drive drives.Drive + Exts map[string]bool + MaxDepth int + // 回调:新视频被加入后触发 teaser 生成 + OnNewVideo func(v *catalog.Video) +} + +func New(cat *catalog.Catalog, drv drives.Drive, exts []string, maxDepth int, onNew func(v *catalog.Video)) *Scanner { + m := make(map[string]bool, len(exts)) + for _, e := range exts { + m[strings.ToLower(e)] = true + } + if maxDepth == 0 { + maxDepth = 5 + } + return &Scanner{ + Catalog: cat, + Drive: drv, + Exts: m, + MaxDepth: maxDepth, + OnNewVideo: onNew, + } +} + +type Stats struct { + Scanned int + Added int +} + +// Run 从 Drive.RootID 开始扫描 +func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) { + if startDirID == "" { + startDirID = s.Drive.RootID() + } + stats := Stats{} + if err := s.walk(ctx, startDirID, "", 0, &stats); err != nil { + return stats, err + } + return stats, nil +} + +func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, stats *Stats) error { + if depth >= s.MaxDepth { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + + entries, err := s.Drive.List(ctx, dirID) + if err != nil { + return fmt.Errorf("list %s: %w", dirID, err) + } + + for _, e := range entries { + if e.IsDir { + // 跳过 previews 目录,避免扫到自己生成的 teaser + if strings.EqualFold(e.Name, "previews") { + continue + } + if err := s.walk(ctx, e.ID, e.Name, depth+1, stats); err != nil { + 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 + } + + id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID + parsed := Parse(e.Name) + if parsed.Title == "" { + parsed.Title = strings.TrimSuffix(e.Name, ext) + } + + existing, _ := s.Catalog.GetVideo(ctx, id) + if existing != nil { + // 已存在但轻量元数据空缺时,顺便补齐。 + patch := catalog.VideoMetaPatch{} + if existing.Category == "" && dirName != "" { + patch.Category = dirName + } + if existing.ThumbnailURL == "" && e.ThumbnailURL != "" { + patch.ThumbnailURL = e.ThumbnailURL + } + if !sameTags(existing.Tags, parsed.Tags) { + patch.Tags = parsed.Tags + patch.TagsSet = true + } + if patch.Category != "" || patch.ThumbnailURL != "" || patch.TagsSet { + _ = s.Catalog.UpdateVideoMeta(ctx, id, patch) + } + continue + } + + now := time.Now() + v := &catalog.Video{ + ID: id, + DriveID: s.Drive.ID(), + FileID: e.ID, + ParentID: e.ParentID, + Title: parsed.Title, + Author: parsed.Author, + Tags: parsed.Tags, + Ext: strings.TrimPrefix(ext, "."), + Quality: "HD", + Size: e.Size, + ThumbnailURL: e.ThumbnailURL, + PreviewStatus: "pending", + Category: dirName, + PublishedAt: orDefault(e.ModTime, now), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Catalog.UpsertVideo(ctx, v); err != nil { + log.Printf("[scanner] upsert %s error: %v", v.Title, err) + continue + } + stats.Added++ + if s.OnNewVideo != nil { + s.OnNewVideo(v) + } + } + return nil +} + +func orDefault(t time.Time, d time.Time) time.Time { + if t.IsZero() { + return d + } + return t +} + +func sameTags(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/scanner/scanner_test.go b/backend/internal/scanner/scanner_test.go new file mode 100644 index 0000000..889cb07 --- /dev/null +++ b/backend/internal/scanner/scanner_test.go @@ -0,0 +1,184 @@ +package scanner + +import ( + "context" + "io" + "testing" + "time" + + "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives" +) + +func TestRunPersistsRemoteThumbnailFromDriveEntry(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, + MimeType: "video/mp4", + ModTime: time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), + ThumbnailURL: "https://thumbnail.example/clip.jpg", + }}, + } + sc := New(cat, drv, []string{".mp4"}, 5, 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) + } + + got, err := cat.GetVideo(ctx, "fake-drive-file-1") + if err != nil { + t.Fatalf("get video: %v", err) + } + if got.ThumbnailURL != "https://thumbnail.example/clip.jpg" { + t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL) + } +} + +func TestRunBackfillsRemoteThumbnailForExistingVideo(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", + Title: "Clip", + PreviewStatus: "pending", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed video: %v", err) + } + + drv := &scannerFakeDrive{ + entries: []drives.Entry{{ + ID: "file-1", + Name: "clip.mp4", + Size: 123, + MimeType: "video/mp4", + ModTime: now, + ThumbnailURL: "https://thumbnail.example/backfilled.jpg", + }}, + } + sc := New(cat, drv, []string{".mp4"}, 5, 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) + } + + got, err := cat.GetVideo(ctx, "fake-drive-file-1") + if err != nil { + t.Fatalf("get video: %v", err) + } + if got.ThumbnailURL != "https://thumbnail.example/backfilled.jpg" { + t.Fatalf("thumbnail = %q, want backfilled remote thumbnail", got.ThumbnailURL) + } +} + +func TestRunReplacesExistingVideoTagsWithFixedFilenameTags(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", + Title: "Old", + Tags: []string{"sunny", "kenny"}, + PreviewStatus: "pending", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed video: %v", err) + } + + drv := &scannerFakeDrive{ + entries: []drives.Entry{{ + ID: "file-1", + Name: "女大后入.mp4", + Size: 123, + ModTime: now, + }}, + } + sc := New(cat, drv, []string{".mp4"}, 5, nil) + + if _, err := sc.Run(ctx, ""); err != nil { + t.Fatalf("scan: %v", err) + } + + got, err := cat.GetVideo(ctx, "fake-drive-file-1") + if err != nil { + t.Fatalf("get video: %v", err) + } + want := []string{"后入", "女大"} + if !sameStrings(got.Tags, want) { + t.Fatalf("tags = %#v, want %#v", got.Tags, want) + } +} + +type scannerFakeDrive struct { + entries []drives.Entry +} + +func (d *scannerFakeDrive) Kind() string { return "fake" } +func (d *scannerFakeDrive) ID() string { return "drive" } +func (d *scannerFakeDrive) Init(context.Context) error { + return nil +} +func (d *scannerFakeDrive) List(context.Context, string) ([]drives.Entry, error) { + return d.entries, nil +} +func (d *scannerFakeDrive) Stat(context.Context, string) (*drives.Entry, error) { + return nil, drives.ErrNotSupported +} +func (d *scannerFakeDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) { + return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil +} +func (d *scannerFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) { + return "", drives.ErrNotSupported +} +func (d *scannerFakeDrive) EnsureDir(context.Context, string) (string, error) { + return "", drives.ErrNotSupported +} +func (d *scannerFakeDrive) RootID() string { return "root" } diff --git a/index.html b/index.html index 51690c5..ddf5f43 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,14 @@ - - - - - - - - 视频聚合站 - - -
- - - + + + + + + + + 视频聚合站 + + +
+ + + diff --git a/package.json b/package.json index 8f67c5b..a610450 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,26 @@ -{ - "name": "video-site", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "dev:raw": "node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173", - "build": "tsc -b && vite build", - "preview": "vite preview", - "lint": "tsc --noEmit" - }, - "dependencies": { - "lucide-react": "0.453.0", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-router-dom": "6.26.2" - }, - "devDependencies": { - "@types/react": "18.3.12", - "@types/react-dom": "18.3.1", - "@vitejs/plugin-react": "4.3.3", - "typescript": "5.6.3", - "vite": "5.4.10" - } -} +{ + "name": "video-site", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:raw": "node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "lucide-react": "0.453.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.26.2" + }, + "devDependencies": { + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "4.3.3", + "typescript": "5.6.3", + "vite": "5.4.10" + } +} diff --git a/public/favicon.svg b/public/favicon.svg index 66dd51a..c6c4a86 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - + diff --git a/src/App.tsx b/src/App.tsx index 2a6f9b1..a5ed5c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,59 +1,59 @@ -import { Navigate, Route, Routes } from "react-router-dom"; -import HomePage from "@/pages/HomePage"; -import ListingPage from "@/pages/ListingPage"; -import VideoDetailPage from "@/pages/VideoDetailPage"; -import { AdminLayout } from "@/admin/AdminLayout"; -import { LoginPage } from "@/admin/LoginPage"; -import { RequireAuth } from "@/admin/RequireAuth"; -import { DrivesPage } from "@/admin/DrivesPage"; -import { VideosPage } from "@/admin/VideosPage"; - -export default function App() { - return ( - - } /> - - {/* 主站需要登录 */} - - - - } - /> - - - - } - /> - - - - } - /> - - {/* 管理后台也需要登录 */} - - - - } - > - } /> - } /> - } /> - - - } /> - - ); -} +import { Navigate, Route, Routes } from "react-router-dom"; +import HomePage from "@/pages/HomePage"; +import ListingPage from "@/pages/ListingPage"; +import VideoDetailPage from "@/pages/VideoDetailPage"; +import { AdminLayout } from "@/admin/AdminLayout"; +import { LoginPage } from "@/admin/LoginPage"; +import { RequireAuth } from "@/admin/RequireAuth"; +import { DrivesPage } from "@/admin/DrivesPage"; +import { VideosPage } from "@/admin/VideosPage"; + +export default function App() { + return ( + + } /> + + {/* 主站需要登录 */} + + + + } + /> + + + + } + /> + + + + } + /> + + {/* 管理后台也需要登录 */} + + + + } + > + } /> + } /> + } /> + + + } /> + + ); +} diff --git a/src/admin/AdminLayout.tsx b/src/admin/AdminLayout.tsx index bd26083..f24eb39 100644 --- a/src/admin/AdminLayout.tsx +++ b/src/admin/AdminLayout.tsx @@ -1,65 +1,65 @@ -import { NavLink, Outlet, useNavigate } from "react-router-dom"; -import { HardDrive, Film, LogOut, Play, Home } from "lucide-react"; -import { useAuth } from "./AuthContext"; -import { useToast } from "./ToastContext"; -import { PreviewToggle } from "./PreviewToggle"; - -export function AdminLayout() { - const { logout } = useAuth(); - const navigate = useNavigate(); - const { show } = useToast(); - - async function handleLogout() { - try { - await logout(); - show("已退出登录", "success"); - navigate("/login", { replace: true }); - } catch { - show("退出失败", "error"); - } - } - - return ( -
- -
- -
-
- ); -} +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { HardDrive, Film, LogOut, Play, Home } from "lucide-react"; +import { useAuth } from "./AuthContext"; +import { useToast } from "./ToastContext"; +import { PreviewToggle } from "./PreviewToggle"; + +export function AdminLayout() { + const { logout } = useAuth(); + const navigate = useNavigate(); + const { show } = useToast(); + + async function handleLogout() { + try { + await logout(); + show("已退出登录", "success"); + navigate("/login", { replace: true }); + } catch { + show("退出失败", "error"); + } + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/admin/AuthContext.tsx b/src/admin/AuthContext.tsx index 832385e..19a1a91 100644 --- a/src/admin/AuthContext.tsx +++ b/src/admin/AuthContext.tsx @@ -1,65 +1,65 @@ -import { - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import * as api from "./api"; - -type AuthStatus = "loading" | "authed" | "guest"; - -type AuthCtx = { - status: AuthStatus; - login: (username: string, password: string) => Promise; - logout: () => Promise; - refresh: () => Promise; -}; - -const Ctx = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [status, setStatus] = useState("loading"); - - const refresh = useCallback(async () => { - try { - const r = await api.me(); - setStatus(r.authenticated ? "authed" : "guest"); - } catch { - setStatus("guest"); - } - }, []); - - // 只在挂载时查一次 - useEffect(() => { - refresh(); - }, [refresh]); - - const login = useCallback(async (u: string, p: string) => { - await api.login(u, p); - setStatus("authed"); - }, []); - - const logout = useCallback(async () => { - try { - await api.logout(); - } finally { - setStatus("guest"); - } - }, []); - - const value = useMemo( - () => ({ status, login, logout, refresh }), - [status, login, logout, refresh] - ); - - return {children}; -} - -export function useAuth(): AuthCtx { - const ctx = useContext(Ctx); - if (!ctx) throw new Error("useAuth must be used inside "); - return ctx; -} +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as api from "./api"; + +type AuthStatus = "loading" | "authed" | "guest"; + +type AuthCtx = { + status: AuthStatus; + login: (username: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +}; + +const Ctx = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + + const refresh = useCallback(async () => { + try { + const r = await api.me(); + setStatus(r.authenticated ? "authed" : "guest"); + } catch { + setStatus("guest"); + } + }, []); + + // 只在挂载时查一次 + useEffect(() => { + refresh(); + }, [refresh]); + + const login = useCallback(async (u: string, p: string) => { + await api.login(u, p); + setStatus("authed"); + }, []); + + const logout = useCallback(async () => { + try { + await api.logout(); + } finally { + setStatus("guest"); + } + }, []); + + const value = useMemo( + () => ({ status, login, logout, refresh }), + [status, login, logout, refresh] + ); + + return {children}; +} + +export function useAuth(): AuthCtx { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useAuth must be used inside "); + return ctx; +} diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index a3f7ad0..0b5db1a 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -1,390 +1,449 @@ -import { useEffect, useMemo, useState } from "react"; -import { Plus, RefreshCw, Trash2 } from "lucide-react"; -import * as api from "./api"; -import { useToast } from "./ToastContext"; -import { Modal } from "./Modal"; - -const kindLabel: Record = { - quark: "夸克网盘", - p115: "115 网盘", - wopan: "联通沃盘", -}; - -type Kind = api.AdminDrive["kind"]; - -type FormState = { - id: string; - kind: Kind; - name: string; - rootId: string; - scanRootId: string; - creds: Record; -}; - -const emptyForm: FormState = { - id: "", - kind: "quark", - name: "", - rootId: "0", - scanRootId: "0", - creds: {}, -}; - -export function DrivesPage() { - const [list, setList] = useState([]); - const [loading, setLoading] = useState(true); - const [modalOpen, setModalOpen] = useState(false); - const [form, setForm] = useState(emptyForm); - const [saving, setSaving] = useState(false); - const { show } = useToast(); - - async function refresh() { - setLoading(true); - try { - const data = await api.listDrives(); - setList(data ?? []); - } catch (e) { - show(e instanceof Error ? e.message : "加载失败", "error"); - } finally { - setLoading(false); - } - } - - useEffect(() => { - refresh(); - }, []); - - function openCreate() { - setForm(emptyForm); - setModalOpen(true); - } - - function openEdit(d: api.AdminDrive) { - setForm({ - id: d.id, - kind: d.kind, - name: d.name, - rootId: d.rootId, - scanRootId: d.scanRootId || d.rootId, - creds: {}, - }); - setModalOpen(true); - } - - async function handleSave() { - if (!form.id || !form.kind) { - show("请填 ID 和类型", "error"); - return; - } - // 若编辑且没有提供凭证,提示一下但仍允许保存(不改凭证) - setSaving(true); - try { - const resp = await api.upsertDrive({ - id: form.id, - kind: form.kind, - name: form.name || form.id, - rootId: form.rootId || "0", - scanRootId: form.scanRootId || form.rootId || "0", - credentials: form.creds, - }); - if (resp.warning) { - show(`已保存,但 driver 初始化失败:${resp.warning}`, "error"); - } else { - show("已保存", "success"); - } - setModalOpen(false); - refresh(); - } catch (e) { - show(e instanceof Error ? e.message : "保存失败", "error"); - } finally { - setSaving(false); - } - } - - async function handleDelete(d: api.AdminDrive) { - if (!window.confirm(`确定删除 ${d.name || d.id}?\n这会移除盘配置,但不会删除其中的视频元数据。`)) return; - try { - await api.deleteDrive(d.id); - show("已删除", "success"); - refresh(); - } catch (e) { - show(e instanceof Error ? e.message : "删除失败", "error"); - } - } - - async function handleRescan(d: api.AdminDrive) { - try { - await api.rescan(d.id); - show("已触发扫描,可稍后刷新视频列表查看", "success"); - } catch (e) { - show(e instanceof Error ? e.message : "触发失败", "error"); - } - } - - return ( -
-
-

网盘管理

- -
- - {loading ? ( -
加载中...
- ) : list.length === 0 ? ( -
- 还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / 沃盘,填入凭证即可。 -
- ) : ( - - - - - - - - - - - - - {list.map((d) => ( - - - - - - - - - ))} - -
名称类型ID状态扫描根操作
{d.name || (未命名)}{kindLabel[d.kind] ?? d.kind}{d.id} - - - {d.scanRootId || d.rootId} - - {" "} - {" "} - -
- )} - - x.id === form.id) ? "编辑网盘" : "新建网盘"} - onClose={() => setModalOpen(false)} - footer={ - <> - - - - } - > - x.id === form.id)} /> - -
- ); -} - -function StatusTag({ - status, - error, - hasCred, -}: { - status: string; - error?: string; - hasCred: boolean; -}) { - if (!hasCred) { - return 未配置凭证; - } - if (status === "ok") return 已连接; - if (status === "error") - return ( - - 错误 - - ); - return {status || "未连接"}; -} - -function DriveForm({ - form, - onChange, - isEdit, -}: { - form: FormState; - onChange: (f: FormState) => void; - isEdit: boolean; -}) { - const fields = useMemo(() => credentialFields(form.kind), [form.kind]); - - function set(k: K, v: FormState[K]) { - onChange({ ...form, [k]: v }); - } - function setCred(k: string, v: string) { - onChange({ ...form, creds: { ...form.creds, [k]: v } }); - } - - return ( -
-
- - set("id", e.target.value)} - placeholder="例如 my-quark" - disabled={isEdit} - /> - {isEdit &&
已创建的盘 ID 不能修改
} -
-
- - set("name", e.target.value)} - placeholder="给这个盘起个名字" - /> -
-
- - -
-
- - set("rootId", e.target.value)} - placeholder="0" - /> -
-
- - set("scanRootId", e.target.value)} - placeholder="留空则使用根目录" - /> -
- 可以指定一个子目录作为视频库入口,避免扫描整个网盘 -
-
- -
- -
- {credentialHelp(form.kind, isEdit)} -
- - {fields.map((f) => ( -
- - {f.multiline ? ( -