mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
74271081f4
Add a pluggable cloud-disk subsystem (internal/service/cloud) exposing remote
files as playable media via HTTP 302 redirects, so the host never transcodes
nor (by default) streams the bytes:
- quark (夸克网盘): cookie auth, directory listing + download_url resolution.
- cloud115 (115 网盘): cookie auth + QR-code login flow (token/poll/exchange);
pickcode → CDN URL resolved for 302 offload.
- StorageConfigService gains cloud types, Ping() probes, List/Resolve/Import.
- New endpoints: admin /cloud/:type/{list,import,qr/start,qr/poll} and authed
/cloud/play/:type (302 redirect, or reverse-proxy when the link needs auth
headers). Imported files become Media rows with STRMURL → the play endpoint.
- Frontend: 115网盘/夸克网盘 tabs with cookie input, 115 QR-code login, a cloud
file browser and one-click 302 import.
Providers are exercised against httptest mock servers (list/resolve/QR state
machine). Live login + playback require a real cloud account.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
package cloud
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// quarkProvider implements the 夸克网盘 cloud disk using cookie auth.
|
|
//
|
|
// Quark's web API is plain JSON over HTTPS keyed by a session cookie; no
|
|
// request-body encryption is required (unlike 115). The resolved download_url
|
|
// is tied to the session, so playback runs in proxy mode by default.
|
|
type quarkProvider struct {
|
|
cookie string
|
|
ua string
|
|
base string // override for tests; defaults to quarkBase
|
|
client *http.Client
|
|
proxy bool
|
|
}
|
|
|
|
const quarkBase = "https://drive-pc.quark.cn/1/clouddrive"
|
|
|
|
func newQuark(cfg map[string]any, client *http.Client) *quarkProvider {
|
|
base := str(cfg["base"])
|
|
if base == "" {
|
|
base = quarkBase
|
|
}
|
|
ua := str(cfg["ua"])
|
|
if ua == "" {
|
|
ua = defaultUA
|
|
}
|
|
// Quark download links require the session cookie + UA, so the host must
|
|
// reverse-proxy unless the admin explicitly opts into raw 302.
|
|
proxy := true
|
|
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
|
|
proxy = false
|
|
}
|
|
return &quarkProvider{
|
|
cookie: str(cfg["cookie"]),
|
|
ua: ua,
|
|
base: strings.TrimRight(base, "/"),
|
|
client: client,
|
|
proxy: proxy,
|
|
}
|
|
}
|
|
|
|
func (q *quarkProvider) Type() string { return TypeQuark }
|
|
|
|
func (q *quarkProvider) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, q.base+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Cookie", q.cookie)
|
|
req.Header.Set("User-Agent", q.ua)
|
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
|
req.Header.Set("Referer", "https://pan.quark.cn/")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return q.client.Do(req)
|
|
}
|
|
|
|
type quarkResp struct {
|
|
Status int `json:"status"`
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func (q *quarkProvider) Ping(ctx context.Context) error {
|
|
if q.cookie == "" {
|
|
return fmt.Errorf("quark: missing cookie")
|
|
}
|
|
_, err := q.List(ctx, "0")
|
|
return err
|
|
}
|
|
|
|
func (q *quarkProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
|
|
if dirID == "" {
|
|
dirID = "0"
|
|
}
|
|
path := fmt.Sprintf("/file/sort?pr=ucpro&fr=pc&uc_param_str=&pdir_fid=%s&_page=1&_size=100&_fetch_total=1&_sort=file_type:asc,updated_at:desc", dirID)
|
|
resp, err := q.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var r quarkResp
|
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
|
return nil, fmt.Errorf("quark: decode list: %w", err)
|
|
}
|
|
if r.Code != 0 && r.Status != 200 {
|
|
return nil, fmt.Errorf("quark: list failed: %s", r.Message)
|
|
}
|
|
var data struct {
|
|
List []struct {
|
|
Fid string `json:"fid"`
|
|
FileName string `json:"file_name"`
|
|
Dir bool `json:"dir"`
|
|
Size int64 `json:"size"`
|
|
} `json:"list"`
|
|
}
|
|
if err := json.Unmarshal(r.Data, &data); err != nil {
|
|
return nil, fmt.Errorf("quark: decode list data: %w", err)
|
|
}
|
|
out := make([]FileEntry, 0, len(data.List))
|
|
for _, it := range data.List {
|
|
out = append(out, FileEntry{
|
|
ID: it.Fid,
|
|
Name: it.FileName,
|
|
IsDir: it.Dir,
|
|
Size: it.Size,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (q *quarkProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
|
if fileRef == "" {
|
|
return nil, fmt.Errorf("quark: empty file id")
|
|
}
|
|
payload, _ := json.Marshal(map[string]any{"fids": []string{fileRef}})
|
|
resp, err := q.do(ctx, http.MethodPost, "/file/download?pr=ucpro&fr=pc&uc_param_str=", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var r quarkResp
|
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
|
return nil, fmt.Errorf("quark: decode download: %w", err)
|
|
}
|
|
if r.Code != 0 && r.Status != 200 {
|
|
return nil, fmt.Errorf("quark: download failed: %s", r.Message)
|
|
}
|
|
var data []struct {
|
|
DownloadURL string `json:"download_url"`
|
|
Fid string `json:"fid"`
|
|
}
|
|
if err := json.Unmarshal(r.Data, &data); err != nil {
|
|
return nil, fmt.Errorf("quark: decode download data: %w", err)
|
|
}
|
|
if len(data) == 0 || data[0].DownloadURL == "" {
|
|
return nil, fmt.Errorf("quark: no download url returned")
|
|
}
|
|
return &DirectLink{
|
|
URL: data[0].DownloadURL,
|
|
Headers: map[string]string{
|
|
"Cookie": q.cookie,
|
|
"User-Agent": q.ua,
|
|
"Referer": "https://pan.quark.cn/",
|
|
},
|
|
Proxy: q.proxy,
|
|
}, nil
|
|
}
|