Files
91/backend/internal/drives/p115/driver.go
T

325 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package p115
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"path"
"strings"
"sync"
"time"
sdk "github.com/SheltonZhu/115driver/pkg/driver"
"github.com/video-site/backend/internal/drives"
)
type Driver struct {
id string
cookie string
rootID string
client *sdk.Pan115Client
ua string
listMu sync.Mutex
lastListAt time.Time
listInterval time.Duration
}
type Config struct {
ID string
Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx"
RootID string // 默认 "0"
UA string // 默认 UA115Browser
}
func New(c Config) *Driver {
rootID := c.RootID
if rootID == "" {
rootID = "0"
}
ua := c.UA
if ua == "" {
ua = sdk.UA115Browser
}
return &Driver{
id: c.ID,
cookie: c.Cookie,
rootID: rootID,
ua: ua,
listInterval: 2 * time.Second,
}
}
func (d *Driver) Kind() string { return "p115" }
func (d *Driver) ID() string { return d.id }
func (d *Driver) RootID() string { return d.rootID }
func (d *Driver) Init(ctx context.Context) error {
cr := &sdk.Credential{}
if err := cr.FromCookie(d.cookie); err != nil {
return fmt.Errorf("parse cookie: %w", err)
}
d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr)
return d.client.LoginCheck()
}
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
files, err := d.listWithRetry(ctx, dirID)
if err != nil {
return nil, fmt.Errorf("115 list: %w", err)
}
if files == nil {
return nil, nil
}
out := make([]drives.Entry, 0, len(*files))
for _, f := range *files {
out = append(out, fileToEntry(&f, dirID))
}
return out, nil
}
func (d *Driver) listWithRetry(ctx context.Context, dirID string) (*[]sdk.File, error) {
d.listMu.Lock()
defer d.listMu.Unlock()
cooldowns := []time.Duration{30 * time.Minute, 30 * time.Minute, 30 * time.Minute}
var lastErr error
for attempt := 0; ; attempt++ {
if err := d.waitForListSlotLocked(ctx); err != nil {
return nil, err
}
files, err := d.client.ListWithLimit(dirID, sdk.MaxDirPageLimit)
if err == nil {
return files, nil
}
lastErr = err
if !isTransient115ListError(err) || attempt >= len(cooldowns) {
break
}
cooldown := cooldowns[attempt]
log.Printf("[p115] list cooling down drive=%s dir=%s cooldown=%s attempt=%d/%d", d.id, dirID, cooldown, attempt+1, len(cooldowns))
if err := sleepContext(ctx, cooldown); err != nil {
return nil, err
}
}
return nil, lastErr
}
func (d *Driver) waitForListSlotLocked(ctx context.Context) error {
if d.listInterval <= 0 || d.lastListAt.IsZero() {
d.lastListAt = time.Now()
return ctx.Err()
}
next := d.lastListAt.Add(d.listInterval)
now := time.Now()
if now.Before(next) {
if err := sleepContext(ctx, next.Sub(now)); err != nil {
return err
}
}
d.lastListAt = time.Now()
return ctx.Err()
}
func sleepContext(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func isTransient115ListError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "405") ||
strings.Contains(text, "429") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "security") ||
strings.Contains(text, "waf") ||
strings.Contains(text, "unexpected error") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "安全威胁")
}
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
f, err := d.client.GetFile(fileID)
if err != nil {
return nil, fmt.Errorf("115 stat: %w", err)
}
if f == nil {
return nil, errors.New("115 stat: not found")
}
e := fileToEntry(f, f.ParentID)
return &e, nil
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
return d.streamURLWithUA(ctx, fileID, d.ua)
}
func (d *Driver) StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error) {
return d.streamURLWithUA(ctx, fileID, header.Get("User-Agent"))
}
func (d *Driver) streamURLWithUA(ctx context.Context, fileID string, ua string) (*drives.StreamLink, error) {
// 需要先拿到 pickCode
f, err := d.client.GetFile(fileID)
if err != nil {
return nil, fmt.Errorf("115 get file: %w", err)
}
info, ua, err := d.downloadInfo(f.PickCode, ua)
if err != nil {
return nil, fmt.Errorf("115 download url: %w", err)
}
if info == nil || info.Url.Url == "" {
return nil, errors.New("115 download url: empty")
}
headers := http.Header{}
// 115 直链会返回一组 Cookie / Refererinfo.Header 里带了
for k, vs := range info.Header {
for _, v := range vs {
headers.Add(k, v)
}
}
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", ua)
}
return &drives.StreamLink{
URL: info.Url.Url,
Headers: headers,
Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量
}, nil
}
func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, string, error) {
ua = strings.TrimSpace(ua)
if ua == "" {
ua = d.ua
}
info, err := d.client.DownloadWithUA(pickCode, ua)
if err != nil {
return nil, "", err
}
return info, ua, nil
}
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
// 115 上传流程比较复杂:RapidUpload -> OSS 分片
// 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS
buf, err := io.ReadAll(r)
if err != nil {
return "", err
}
rs := strings.NewReader(string(buf))
if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil {
return "", fmt.Errorf("115 upload: %w", err)
}
// RapidUploadOrByOSS 目前没返回 fileID,需要回查
files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit)
if err != nil {
return "", fmt.Errorf("115 upload verify: %w", err)
}
if files != nil {
for _, f := range *files {
if !f.IsDirectory && f.Name == name {
return f.FileID, nil
}
}
}
return "", errors.New("115 upload: file not found after upload")
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
parts := splitPath(pathFromRoot)
currentID := d.rootID
for _, name := range parts {
childID, err := d.findChildDir(ctx, currentID, name)
if err != nil {
return "", err
}
if childID == "" {
id, err := d.client.Mkdir(currentID, name)
if err != nil {
return "", fmt.Errorf("115 mkdir %s: %w", name, err)
}
childID = id
}
currentID = childID
}
return currentID, nil
}
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
entries, err := d.List(ctx, parent)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir && e.Name == name {
return e.ID, nil
}
}
return "", nil
}
func splitPath(p string) []string {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
return drives.Entry{
ID: f.FileID,
Name: f.Name,
Size: f.Size,
Hash: f.Sha1,
IsDir: f.IsDirectory,
ParentID: parentID,
MimeType: guessMime(f.Name),
ModTime: f.UpdateTime,
ThumbnailURL: f.ThumbURL,
}
}
func guessMime(name string) string {
ext := strings.ToLower(path.Ext(name))
switch ext {
case ".mp4":
return "video/mp4"
case ".mkv":
return "video/x-matroska"
case ".mov":
return "video/quicktime"
case ".webm":
return "video/webm"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
}
return "application/octet-stream"
}
var _ drives.Drive = (*Driver)(nil)