mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
3506328441
Add PikPak backend driver, fixed tag matching, cached transcode playback, fast cover handling, and LF normalization.
346 lines
8.1 KiB
Go
346 lines
8.1 KiB
Go
package quark
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
"github.com/video-site/backend/internal/drives"
|
||
)
|
||
|
||
const (
|
||
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
|
||
defaultReferer = "https://pan.quark.cn"
|
||
defaultAPI = "https://drive.quark.cn/1/clouddrive"
|
||
defaultPR = "ucpro"
|
||
)
|
||
|
||
type Driver struct {
|
||
id string
|
||
cookie string
|
||
rootID string
|
||
ua string
|
||
referer string
|
||
apiBase string
|
||
pr string
|
||
client *resty.Client
|
||
onCookieUpdate func(string)
|
||
useTranscodingAddress bool
|
||
}
|
||
|
||
type Config struct {
|
||
ID string
|
||
Cookie string
|
||
RootID string
|
||
UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致
|
||
OnCookieUpdate func(cookie string)
|
||
}
|
||
|
||
func New(c Config) *Driver {
|
||
rootID := c.RootID
|
||
if rootID == "" {
|
||
rootID = "0"
|
||
}
|
||
d := &Driver{
|
||
id: c.ID,
|
||
cookie: c.Cookie,
|
||
rootID: rootID,
|
||
ua: defaultUA,
|
||
referer: defaultReferer,
|
||
apiBase: defaultAPI,
|
||
pr: defaultPR,
|
||
useTranscodingAddress: c.UseTranscodingAddress,
|
||
onCookieUpdate: c.OnCookieUpdate,
|
||
}
|
||
d.client = resty.New().
|
||
SetTimeout(30 * time.Second).
|
||
SetHeader("Accept", "application/json, text/plain, */*").
|
||
SetHeader("Referer", d.referer).
|
||
SetHeader("User-Agent", d.ua)
|
||
return d
|
||
}
|
||
|
||
func (d *Driver) Kind() string { return "quark" }
|
||
func (d *Driver) ID() string { return d.id }
|
||
func (d *Driver) RootID() string { return d.rootID }
|
||
|
||
// ---------- 公共请求 ----------
|
||
|
||
type resp struct {
|
||
Status int `json:"status"`
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error {
|
||
req := d.client.R().
|
||
SetContext(ctx).
|
||
SetHeader("Cookie", d.cookie).
|
||
SetQueryParam("pr", d.pr).
|
||
SetQueryParam("fr", "pc")
|
||
if query != nil {
|
||
req.SetQueryParams(query)
|
||
}
|
||
if body != nil {
|
||
req.SetBody(body)
|
||
}
|
||
if out != nil {
|
||
req.SetResult(out)
|
||
}
|
||
var e resp
|
||
req.SetError(&e)
|
||
|
||
res, err := req.Execute(method, d.apiBase+path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 处理 cookie 刷新(__puus)
|
||
for _, ck := range res.Cookies() {
|
||
if ck.Name == "__puus" {
|
||
d.cookie = setCookieValue(d.cookie, "__puus", ck.Value)
|
||
if d.onCookieUpdate != nil {
|
||
d.onCookieUpdate(d.cookie)
|
||
}
|
||
}
|
||
}
|
||
|
||
if e.Status >= 400 || e.Code != 0 {
|
||
if e.Message == "" {
|
||
return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code)
|
||
}
|
||
return errors.New(e.Message)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (d *Driver) Init(ctx context.Context) error {
|
||
return d.request(ctx, "/config", http.MethodGet, nil, nil, nil)
|
||
}
|
||
|
||
// ---------- 列目录 ----------
|
||
|
||
type file struct {
|
||
Fid string `json:"fid"`
|
||
FileName string `json:"file_name"`
|
||
Size int64 `json:"size"`
|
||
Category int `json:"category"`
|
||
File bool `json:"file"`
|
||
UpdatedAt int64 `json:"updated_at"`
|
||
}
|
||
|
||
type sortResp struct {
|
||
Data struct {
|
||
List []file `json:"list"`
|
||
} `json:"data"`
|
||
Metadata struct {
|
||
Total int `json:"_total"`
|
||
} `json:"metadata"`
|
||
}
|
||
|
||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||
var out []drives.Entry
|
||
page := 1
|
||
size := 100
|
||
for {
|
||
q := map[string]string{
|
||
"pdir_fid": dirID,
|
||
"_size": strconv.Itoa(size),
|
||
"_page": strconv.Itoa(page),
|
||
"_fetch_total": "1",
|
||
"fetch_all_file": "1",
|
||
"fetch_risk_file_name": "1",
|
||
}
|
||
var r sortResp
|
||
if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil {
|
||
return nil, fmt.Errorf("quark list: %w", err)
|
||
}
|
||
for _, f := range r.Data.List {
|
||
out = append(out, fileToEntry(&f, dirID))
|
||
}
|
||
if page*size >= r.Metadata.Total {
|
||
break
|
||
}
|
||
page++
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||
// 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息
|
||
return nil, drives.ErrNotSupported
|
||
}
|
||
|
||
// ---------- 下载直链 ----------
|
||
|
||
type downResp struct {
|
||
Data []struct {
|
||
DownloadUrl string `json:"download_url"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||
body := map[string]any{"fids": []string{fileID}}
|
||
var r downResp
|
||
if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil {
|
||
return nil, fmt.Errorf("quark download: %w", err)
|
||
}
|
||
if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" {
|
||
return nil, errors.New("quark download: empty url")
|
||
}
|
||
|
||
headers := http.Header{}
|
||
headers.Set("User-Agent", d.ua)
|
||
headers.Set("Referer", d.referer)
|
||
headers.Set("Cookie", d.cookie)
|
||
|
||
return &drives.StreamLink{
|
||
URL: r.Data[0].DownloadUrl,
|
||
Headers: headers,
|
||
Expires: time.Now().Add(10 * time.Minute),
|
||
}, nil
|
||
}
|
||
|
||
// ---------- 创建目录 ----------
|
||
|
||
type mkdirResp struct {
|
||
Data struct {
|
||
Fid string `json:"fid"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) {
|
||
body := map[string]any{
|
||
"dir_init_lock": false,
|
||
"dir_path": "",
|
||
"file_name": name,
|
||
"pdir_fid": parentID,
|
||
}
|
||
var r mkdirResp
|
||
if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil {
|
||
return "", fmt.Errorf("quark mkdir: %w", err)
|
||
}
|
||
return r.Data.Fid, nil
|
||
}
|
||
|
||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||
parts := splitPath(pathFromRoot)
|
||
currentID := d.rootID
|
||
for _, name := range parts {
|
||
childID, err := d.findChildDir(ctx, currentID, name)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if childID == "" {
|
||
id, err := d.MakeDir(ctx, currentID, name)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
childID = id
|
||
}
|
||
currentID = childID
|
||
}
|
||
return currentID, nil
|
||
}
|
||
|
||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||
entries, err := d.List(ctx, parent)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir && e.Name == name {
|
||
return e.ID, nil
|
||
}
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
|
||
|
||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||
return "", drives.ErrNotSupported
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
|
||
func fileToEntry(f *file, parentID string) drives.Entry {
|
||
return drives.Entry{
|
||
ID: f.Fid,
|
||
Name: f.FileName,
|
||
Size: f.Size,
|
||
IsDir: !f.File,
|
||
ParentID: parentID,
|
||
MimeType: guessMime(f.FileName),
|
||
ModTime: time.UnixMilli(f.UpdatedAt),
|
||
Category: f.Category,
|
||
}
|
||
}
|
||
|
||
func guessMime(name string) string {
|
||
ext := strings.ToLower(path.Ext(name))
|
||
switch ext {
|
||
case ".mp4":
|
||
return "video/mp4"
|
||
case ".mkv":
|
||
return "video/x-matroska"
|
||
case ".mov":
|
||
return "video/quicktime"
|
||
case ".webm":
|
||
return "video/webm"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
|
||
func splitPath(p string) []string {
|
||
p = strings.Trim(p, "/")
|
||
if p == "" {
|
||
return nil
|
||
}
|
||
return strings.Split(p, "/")
|
||
}
|
||
|
||
// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加
|
||
func setCookieValue(cookie, key, value string) string {
|
||
if cookie == "" {
|
||
return key + "=" + value
|
||
}
|
||
parts := strings.Split(cookie, ";")
|
||
var out []string
|
||
found := false
|
||
for _, p := range parts {
|
||
kv := strings.TrimSpace(p)
|
||
if kv == "" {
|
||
continue
|
||
}
|
||
eq := strings.IndexByte(kv, '=')
|
||
if eq < 0 {
|
||
out = append(out, kv)
|
||
continue
|
||
}
|
||
if kv[:eq] == key {
|
||
out = append(out, key+"="+value)
|
||
found = true
|
||
} else {
|
||
out = append(out, kv)
|
||
}
|
||
}
|
||
if !found {
|
||
out = append(out, key+"="+value)
|
||
}
|
||
return strings.Join(out, "; ")
|
||
}
|
||
|
||
var _ drives.Drive = (*Driver)(nil)
|