mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Add PikPak drive support
Add PikPak backend driver, fixed tag matching, cached transcode playback, fast cover handling, and LF normalization.
This commit is contained in:
@@ -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
|
||||
@@ -8,6 +8,7 @@ dist
|
||||
|
||||
# 第三方源码参考,可选,详见 vendor-refs/README.md
|
||||
vendor-refs/
|
||||
OpenList-4.2.1/
|
||||
|
||||
# 后端数据目录(SQLite + teaser/封面)
|
||||
backend/data/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
把夸克 / 115 / PikPak / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
|
||||
- 前端:React 18 + Vite + TypeScript
|
||||
- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
|
||||
- 三家网盘接入:夸克自研 + 115driver SDK + wopan-sdk-go SDK
|
||||
- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList)+ wopan-sdk-go SDK
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -70,17 +70,18 @@ git add vendor/ # 入库
|
||||
## 加一个网盘
|
||||
|
||||
1. 登录 `/admin` → 网盘管理 → 新建
|
||||
2. 选类型(夸克 / 115 / 沃盘),填名称 + 凭证
|
||||
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 和封面生成策略
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 三家网盘统一抽象(夸克 / 115 / 联通沃盘)
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
|
||||
@@ -17,6 +17,7 @@ internal/
|
||||
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
|
||||
@@ -81,12 +82,13 @@ go run ./cmd/server 后端 8080
|
||||
```
|
||||
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` |
|
||||
|
||||
## 文件名约定
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"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"
|
||||
@@ -56,6 +57,7 @@ func main() {
|
||||
cat: cat,
|
||||
registry: proxy.NewRegistry(),
|
||||
workers: make(map[string]*preview.Worker),
|
||||
thumbWorkers: make(map[string]*preview.ThumbWorker),
|
||||
}
|
||||
app.proxy = proxy.New(app.registry)
|
||||
|
||||
@@ -85,6 +87,7 @@ func main() {
|
||||
Catalog: cat,
|
||||
Proxy: app.proxy,
|
||||
LocalDir: cfg.Storage.LocalPreviewDir,
|
||||
FFmpegPath: cfg.Preview.FFmpegPath,
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
@@ -154,6 +157,7 @@ type App struct {
|
||||
|
||||
mu sync.Mutex
|
||||
workers map[string]*preview.Worker
|
||||
thumbWorkers map[string]*preview.ThumbWorker
|
||||
cancels map[string]context.CancelFunc
|
||||
|
||||
// 运行时 preview 开关(从 DB 读)
|
||||
@@ -235,6 +239,26 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
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,
|
||||
@@ -276,9 +300,11 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
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 {
|
||||
@@ -288,14 +314,10 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
old()
|
||||
}
|
||||
a.workers[d.ID] = worker
|
||||
a.thumbWorkers[d.ID] = thumbWorker
|
||||
a.cancels[d.ID] = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
// 启动补扫:把这个盘下所有 pending 的视频塞进 worker 队列
|
||||
// 使用 goroutine 因为队列可能比预期的小,Enqueue 直接丢弃,调用方也无需等待
|
||||
if a.PreviewEnabled() {
|
||||
go a.enqueuePending(workerCtx, d.ID, worker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -314,6 +336,21 @@ func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Wor
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -322,6 +359,7 @@ func (a *App) detachDrive(id string) {
|
||||
delete(a.cancels, id)
|
||||
}
|
||||
delete(a.workers, id)
|
||||
delete(a.thumbWorkers, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -334,11 +372,19 @@ func (a *App) runScan(ctx context.Context, driveID string) {
|
||||
|
||||
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 {
|
||||
onNew = worker.Enqueue
|
||||
worker.Enqueue(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew)
|
||||
@@ -361,6 +407,12 @@ func (a *App) runScan(ctx context.Context, driveID string) {
|
||||
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) {
|
||||
|
||||
@@ -37,5 +37,6 @@ preview:
|
||||
# teaser 上传到网盘的目录(相对网盘根)。空值则写本地
|
||||
remote_dir: "/previews"
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空。
|
||||
# kind 支持 quark / p115 / pikpak / wopan。
|
||||
drives: []
|
||||
|
||||
@@ -138,6 +138,11 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request)
|
||||
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,
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
+217
-7
@@ -1,18 +1,23 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -20,6 +25,10 @@ type Server struct {
|
||||
Catalog *catalog.Catalog
|
||||
Proxy *proxy.Proxy
|
||||
LocalDir string
|
||||
FFmpegPath string
|
||||
|
||||
transcodeMu sync.Mutex
|
||||
transcodeJobs map[string]bool
|
||||
}
|
||||
|
||||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||||
@@ -84,6 +93,9 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
|
||||
// 代理路由同样需要鉴权,防止绕过
|
||||
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)
|
||||
})
|
||||
@@ -141,8 +153,8 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
detail := VideoDetailDTO{
|
||||
VideoDTO: mapVideo(v),
|
||||
VideoSrc: fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID),
|
||||
Poster: v.ThumbnailURL,
|
||||
VideoSrc: videoSource(v),
|
||||
Poster: thumbnailURL(v),
|
||||
Description: v.Description,
|
||||
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
|
||||
AuthorProfile: AuthorProfile{
|
||||
@@ -158,7 +170,7 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := s.Catalog.ListCategories(r.Context())
|
||||
stats, err := s.Catalog.CountTags(r.Context(), fixedtags.Labels)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -168,9 +180,9 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
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)
|
||||
}
|
||||
@@ -191,6 +203,130 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -227,9 +363,11 @@ func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -248,7 +386,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
|
||||
ID: v.ID,
|
||||
Href: "/video/" + v.ID,
|
||||
Title: v.Title,
|
||||
Thumbnail: v.ThumbnailURL,
|
||||
Thumbnail: thumbnailURL(v),
|
||||
PreviewSrc: "/p/preview/" + v.ID,
|
||||
PreviewDuration: 10,
|
||||
PreviewStrategy: "teaser-file",
|
||||
@@ -267,6 +405,78 @@ func mapVideo(v *catalog.Video) VideoDTO {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -152,6 +152,8 @@ 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 {
|
||||
@@ -169,6 +171,11 @@ func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPat
|
||||
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
|
||||
}
|
||||
@@ -208,6 +215,26 @@ func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) {
|
||||
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 {
|
||||
@@ -231,6 +258,33 @@ func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status
|
||||
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)
|
||||
@@ -245,7 +299,8 @@ type ListParams struct {
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) { if p.PageSize <= 0 {
|
||||
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) {
|
||||
if p.PageSize <= 0 {
|
||||
p.PageSize = 24
|
||||
}
|
||||
if p.Page <= 0 {
|
||||
|
||||
@@ -35,7 +35,7 @@ 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
|
||||
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)
|
||||
|
||||
@@ -52,7 +52,7 @@ type Preview struct {
|
||||
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
|
||||
type Drive struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / wopan
|
||||
Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "wopan"
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
@@ -52,6 +52,7 @@ type Entry struct {
|
||||
|
||||
// 部分网盘额外信息
|
||||
Category int // 1=视频 (quark)
|
||||
ThumbnailURL string // 网盘侧已提供的快速缩略图
|
||||
}
|
||||
|
||||
type StreamLink struct {
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,6 +31,17 @@ 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"
|
||||
@@ -362,14 +373,14 @@ func copyFile(src, dst string) error {
|
||||
// --- Worker ---
|
||||
|
||||
type Worker struct {
|
||||
Gen *Generator
|
||||
Gen TeaserGenerator
|
||||
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 {
|
||||
func NewWorker(gen TeaserGenerator, cat *catalog.Catalog, drv drives.Drive, remoteDir string) *Worker {
|
||||
return &Worker{
|
||||
Gen: gen,
|
||||
Catalog: cat,
|
||||
@@ -386,6 +397,29 @@ func (w *Worker) Enqueue(v *catalog.Video) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -403,6 +437,50 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -422,16 +500,7 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 2) teaser
|
||||
tmp, err := w.Gen.Generate(ctx, link, duration)
|
||||
if err != nil {
|
||||
log.Printf("[preview] generate %s: %v", v.Title, err)
|
||||
|
||||
@@ -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" }
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/video-site/backend/internal/fixedtags"
|
||||
)
|
||||
|
||||
// ParsedName 从文件名里解析出的视频元数据
|
||||
@@ -26,16 +28,6 @@ func Parse(filename string) ParsedName {
|
||||
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]):])
|
||||
}
|
||||
|
||||
@@ -45,5 +37,6 @@ func Parse(filename string) ParsedName {
|
||||
}
|
||||
|
||||
out.Title = strings.TrimSpace(name)
|
||||
out.Tags = fixedtags.MatchFilename(filename)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -87,20 +87,31 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -113,6 +124,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
Quality: "HD",
|
||||
Size: e.Size,
|
||||
ThumbnailURL: e.ThumbnailURL,
|
||||
PreviewStatus: "pending",
|
||||
Category: dirName,
|
||||
PublishedAt: orDefault(e.ModTime, now),
|
||||
@@ -137,3 +149,15 @@ func orDefault(t time.Time, d time.Time) time.Time {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -7,6 +7,7 @@ import { Modal } from "./Modal";
|
||||
const kindLabel: Record<string, string> = {
|
||||
quark: "夸克网盘",
|
||||
p115: "115 网盘",
|
||||
pikpak: "PikPak",
|
||||
wopan: "联通沃盘",
|
||||
};
|
||||
|
||||
@@ -83,8 +84,8 @@ export function DrivesPage() {
|
||||
id: form.id,
|
||||
kind: form.kind,
|
||||
name: form.name || form.id,
|
||||
rootId: form.rootId || "0",
|
||||
scanRootId: form.scanRootId || form.rootId || "0",
|
||||
rootId: form.rootId || defaultRootId(form.kind),
|
||||
scanRootId: form.scanRootId || form.rootId || defaultRootId(form.kind),
|
||||
credentials: form.creds,
|
||||
});
|
||||
if (resp.warning) {
|
||||
@@ -134,7 +135,7 @@ export function DrivesPage() {
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / 沃盘,填入凭证即可。
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / PikPak / 沃盘,填入凭证即可。
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
@@ -241,6 +242,15 @@ function DriveForm({
|
||||
function setCred(k: string, v: string) {
|
||||
onChange({ ...form, creds: { ...form.creds, [k]: v } });
|
||||
}
|
||||
function setKind(v: Kind) {
|
||||
onChange({
|
||||
...form,
|
||||
kind: v,
|
||||
rootId: defaultRootId(v),
|
||||
scanRootId: defaultRootId(v),
|
||||
creds: {},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
@@ -266,11 +276,12 @@ function DriveForm({
|
||||
<label>类型</label>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => set("kind", e.target.value as Kind)}
|
||||
onChange={(e) => setKind(e.target.value as Kind)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<option value="quark">夸克网盘</option>
|
||||
<option value="p115">115 网盘</option>
|
||||
<option value="pikpak">PikPak</option>
|
||||
<option value="wopan">联通沃盘</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -279,7 +290,7 @@ function DriveForm({
|
||||
<input
|
||||
value={form.rootId}
|
||||
onChange={(e) => set("rootId", e.target.value)}
|
||||
placeholder="0"
|
||||
placeholder={form.kind === "pikpak" ? "留空表示根目录" : "0"}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
@@ -330,6 +341,8 @@ function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
return `在 pan.quark.cn 登录后,F12 → Network → 任意请求 → Request Headers 里复制整段 Cookie 粘贴到下方。${note}`;
|
||||
case "p115":
|
||||
return `登录 115.com 后复制 Cookie,形如 "UID=...; CID=...; SEID=...; KID=..."。${note}`;
|
||||
case "pikpak":
|
||||
return `参考 OpenList 的 PikPak 登录方式。可填用户名和密码首次登录,也可填 refresh_token;如返回验证码链接,打开验证后把 captcha_token 粘贴回来。${note}`;
|
||||
case "wopan":
|
||||
return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴。${note}`;
|
||||
default:
|
||||
@@ -366,6 +379,48 @@ function credentialFields(kind: Kind): Array<{
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
case "pikpak":
|
||||
return [
|
||||
{
|
||||
key: "username",
|
||||
label: "用户名 / 邮箱(无 refresh_token 时必填)",
|
||||
placeholder: "user@example.com",
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
label: "密码(无 refresh_token 时必填)",
|
||||
placeholder: "PikPak 密码",
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "platform",
|
||||
placeholder: "web(可选:android / web / pc)",
|
||||
help: "默认 web;如果登录或直链异常,可尝试 android 或 pc。",
|
||||
},
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token(可选)",
|
||||
placeholder: "已有 token 时可直接粘贴",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: "captcha_token",
|
||||
label: "captcha_token(可选)",
|
||||
placeholder: "遇到验证码校验时粘贴",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: "device_id",
|
||||
label: "device_id(可选)",
|
||||
placeholder: "留空自动生成并保存",
|
||||
},
|
||||
{
|
||||
key: "disable_media_link",
|
||||
label: "disable_media_link",
|
||||
placeholder: "true",
|
||||
help: "默认 true,使用原始下载链接;填 false 可尝试使用媒体缓存链接。",
|
||||
},
|
||||
];
|
||||
case "wopan":
|
||||
return [
|
||||
{
|
||||
@@ -388,3 +443,7 @@ function credentialFields(kind: Kind): Array<{
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRootId(kind: Kind): string {
|
||||
return kind === "pikpak" ? "" : "0";
|
||||
}
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ export function me() {
|
||||
|
||||
export type AdminDrive = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "wopan";
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
@@ -69,7 +69,7 @@ export function listDrives() {
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "wopan";
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchTags, type TagItem } from "@/data/videos";
|
||||
|
||||
export function TagCloud() {
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get("cat");
|
||||
const activeTag = params.get("tag");
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,7 +25,7 @@ export function TagCloud() {
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/list?cat=${encodeURIComponent(tag.label)}`}
|
||||
to={`/list?tag=${encodeURIComponent(tag.label)}`}
|
||||
className={`tag-chip ${activeTag === tag.label ? "is-active" : ""}`}
|
||||
title={
|
||||
typeof tag.count === "number" ? `${tag.count} 个视频` : undefined
|
||||
|
||||
@@ -24,9 +24,11 @@ export function VideoCard({ video }: Props) {
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const [shouldRenderPreview, setShouldRenderPreview] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0~1
|
||||
const [thumbnailRetry, setThumbnailRetry] = useState(0);
|
||||
|
||||
const rootRef = useRef<HTMLElement | null>(null);
|
||||
const hoverTimerRef = useRef<number | null>(null);
|
||||
const thumbnailRetryTimerRef = useRef<number | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const activeId = useActivePreviewId();
|
||||
@@ -50,10 +52,23 @@ export function VideoCard({ video }: Props) {
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
return () => {
|
||||
cleanup();
|
||||
if (thumbnailRetryTimerRef.current) {
|
||||
window.clearTimeout(thumbnailRetryTimerRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnailRetry(0);
|
||||
if (thumbnailRetryTimerRef.current) {
|
||||
window.clearTimeout(thumbnailRetryTimerRef.current);
|
||||
thumbnailRetryTimerRef.current = null;
|
||||
}
|
||||
}, [video.id, video.thumbnail]);
|
||||
|
||||
function cleanup() {
|
||||
if (hoverTimerRef.current) {
|
||||
window.clearTimeout(hoverTimerRef.current);
|
||||
@@ -80,6 +95,21 @@ export function VideoCard({ video }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleThumbnailError() {
|
||||
if (!video.thumbnail.startsWith("/p/thumb/")) return;
|
||||
if (thumbnailRetry >= 8 || thumbnailRetryTimerRef.current) return;
|
||||
|
||||
thumbnailRetryTimerRef.current = window.setTimeout(() => {
|
||||
thumbnailRetryTimerRef.current = null;
|
||||
setThumbnailRetry((n) => n + 1);
|
||||
}, Math.min(1000 + thumbnailRetry * 750, 5000));
|
||||
}
|
||||
|
||||
const thumbnailSrc =
|
||||
thumbnailRetry === 0
|
||||
? video.thumbnail
|
||||
: withRetryParam(video.thumbnail, thumbnailRetry);
|
||||
|
||||
function startPreviewIntent() {
|
||||
if (!inView) return;
|
||||
setPreviewState("intent");
|
||||
@@ -113,9 +143,10 @@ export function VideoCard({ video }: Props) {
|
||||
<div className="thumb-frame">
|
||||
<img
|
||||
className="thumb-image"
|
||||
src={video.thumbnail}
|
||||
src={thumbnailSrc}
|
||||
alt={video.title}
|
||||
loading="lazy"
|
||||
onError={handleThumbnailError}
|
||||
/>
|
||||
|
||||
{shouldRenderPreview && (
|
||||
@@ -180,3 +211,8 @@ export function VideoCard({ video }: Props) {
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function withRetryParam(src: string, retry: number): string {
|
||||
const sep = src.includes("?") ? "&" : "?";
|
||||
return `${src}${sep}r=${retry}`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
poster: string;
|
||||
@@ -5,16 +7,81 @@ type Props = {
|
||||
};
|
||||
|
||||
export function VideoPlayer({ src, poster, title }: Props) {
|
||||
const isTranscode = src.includes("/p/transcode/");
|
||||
const [playbackSrc, setPlaybackSrc] = useState(isTranscode ? "" : src);
|
||||
const [transcodeStatus, setTranscodeStatus] = useState<
|
||||
"idle" | "processing" | "error"
|
||||
>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTranscode) {
|
||||
setPlaybackSrc(src);
|
||||
setTranscodeStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let timer: number | null = null;
|
||||
|
||||
async function poll(start: boolean) {
|
||||
try {
|
||||
const statusResp = await fetch(`${src}/status`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!statusResp.ok) throw new Error("status failed");
|
||||
const statusBody = (await statusResp.json()) as { status?: string };
|
||||
if (!active) return;
|
||||
|
||||
if (statusBody.status === "ready") {
|
||||
setPlaybackSrc(src);
|
||||
setTranscodeStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
await fetch(`${src}/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("processing");
|
||||
timer = window.setTimeout(() => poll(false), 3000);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("processing");
|
||||
void poll(true);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [isTranscode, src]);
|
||||
|
||||
return (
|
||||
<div className="video-player">
|
||||
<video
|
||||
src={src}
|
||||
src={playbackSrc || undefined}
|
||||
poster={poster}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
aria-label={title}
|
||||
/>
|
||||
{isTranscode && !playbackSrc && (
|
||||
<div className="video-player__status">
|
||||
{transcodeStatus === "error"
|
||||
? "转码启动失败,请稍后重试"
|
||||
: "正在准备可快进版本..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-player__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1304,10 +1304,11 @@ VideoProject/
|
||||
├─ backend/ Go 单体服务
|
||||
│ ├─ cmd/server/main.go
|
||||
│ ├─ internal/
|
||||
│ │ ├─ drives/ Drive 接口 + 三家实现
|
||||
│ │ ├─ drives/ Drive 接口 + 多家实现
|
||||
│ │ │ ├─ iface.go List / Stat / StreamURL / RefreshAuth
|
||||
│ │ │ ├─ quark/ 自己实现(参考 OpenList quark_uc)
|
||||
│ │ │ ├─ p115/ 壳 + SheltonZhu/115driver
|
||||
│ │ │ ├─ pikpak/ 自己实现(参考 OpenList pikpak)
|
||||
│ │ │ └─ wopan/ 壳 + OpenListTeam/wopan-sdk-go
|
||||
│ │ ├─ catalog/ SQLite + VideoItem 增删改查
|
||||
│ │ ├─ scanner/ 扫目录 → 落库 + 异步抽 teaser
|
||||
@@ -1330,6 +1331,7 @@ VideoProject/
|
||||
- **SDK**:
|
||||
- 夸克:移植 OpenList `drivers/quark_uc` 的 HTTP 逻辑(纯 Cookie + resty)。
|
||||
- 115:`github.com/SheltonZhu/115driver`,通过 `replace` 指令指向 `../115driver-1.3.2`。
|
||||
- PikPak:移植 OpenList `drivers/pikpak` 的 HTTP 逻辑(用户名密码 / refresh_token + captcha_token + resty);第一版支持扫描和播放,teaser 上传走本地兜底。
|
||||
- 沃盘:`github.com/OpenListTeam/wopan-sdk-go`,`replace` 指向 `../wopan-sdk-go-0.2.0`。
|
||||
- **视频处理**:ffmpeg / ffprobe,作为外部子进程调用。
|
||||
- **部署**:本地 Windows 开发,最终部署到 Linux 服务器(二进制 + systemd + nginx 反代)。
|
||||
@@ -1474,6 +1476,7 @@ POST /admin/api/videos/:id/regen-preview
|
||||
|
||||
- **115 扫码**:`POST /admin/api/drives/:id/login` 返回二维码图片;前端轮询 `.../login/status` 直到成功
|
||||
- **夸克**:最稳是让用户在电脑浏览器登录 pan.quark.cn 后 F12 复制 Cookie,后台粘贴保存。可选:实现扫码登录(OpenList 社区有方案)
|
||||
- **PikPak**:参考 OpenList,后台粘贴 username/password 或 refresh_token;遇到 captcha URL 时手动验证后回填 captcha_token
|
||||
- **沃盘**:手机号 → 后端请求短信 → 前端填验证码 → 登录
|
||||
|
||||
### 15.9 前端改动
|
||||
|
||||
+5
-4
@@ -10,11 +10,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
port: 9191,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/p": "http://localhost:8080",
|
||||
"/admin/api": "http://localhost:8080",
|
||||
"/api": "http://127.0.0.1:9192",
|
||||
"/p": "http://127.0.0.1:9192",
|
||||
"/admin/api": "http://127.0.0.1:9192",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user