feat(cloud): 115/夸克网盘 providers with cookie + QR login and 302 playback

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>
This commit is contained in:
soldosluka857
2026-05-30 11:22:31 +00:00
committed by Shuke
parent 8fd2ab4b51
commit c6455103e6
9 changed files with 1281 additions and 9 deletions
+130
View File
@@ -0,0 +1,130 @@
// Package handler — cloud-disk (网盘) endpoints: directory browsing, QR-code
// login, media import and 302 playback redirects.
package handler
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// cloudListHandler browses a configured cloud disk directory.
func cloudListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
typ := c.Param("type")
dir := c.Query("dir")
entries, err := svc.StorageCfg.CloudList(c.Request.Context(), typ, dir)
if err != nil {
c.JSON(http.StatusOK, gin.H{"error": err.Error(), "items": []any{}})
return
}
c.JSON(http.StatusOK, gin.H{"items": entries})
}
}
// cloudImportHandler turns a cloud file into a playable 302-backed media item.
func cloudImportHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
typ := c.Param("type")
var in struct {
Ref string `json:"ref" binding:"required"`
Name string `json:"name"`
Size int64 `json:"size"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
m, err := svc.StorageCfg.CloudImport(c.Request.Context(), typ, in.Ref, in.Name, in.Size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
}
// cloud115QRStartHandler begins a 115 QR-code login and returns the session +
// QR image URL for the frontend to render.
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sess, err := cloud.QRStart(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sess)
}
}
// cloud115QRPollHandler polls a 115 QR session; on confirmation it returns the
// session cookie so the frontend can save it as the storage credential.
func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var sess cloud.QRSession
if err := c.ShouldBindJSON(&sess); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := cloud.QRPoll(c.Request.Context(), nil, &sess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, st)
}
}
// cloudPlayHandler resolves a cloud file to its direct link and either issues a
// 302 redirect (true offload — host does not stream the bytes) or, when the
// provider requires authenticated headers, reverse-proxies the response.
func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
typ := c.Param("type")
ref := c.Query("ref")
if ref == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
return
}
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !link.Proxy {
// Pure offload: send the client straight to the cloud CDN.
c.Redirect(http.StatusFound, link.URL)
return
}
// Proxy mode: the direct link needs auth headers the browser cannot
// carry. Stream through with Range forwarding.
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, link.URL, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
if rng := c.GetHeader("Range"); rng != "" {
req.Header.Set("Range", rng)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
}
+11 -1
View File
@@ -94,6 +94,10 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
// Cloud-disk 302 playback redirect (resolves a fresh direct link).
authed.GET("/cloud/play/:type", cloudPlayHandler(svc))
authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc))
// Image proxy (URL passed as ?url=...).
authed.GET("/img", imageProxyHandler(svc))
@@ -305,12 +309,18 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
// Storage configs (Alist / S3 / WebDAV).
// Storage configs (Alist / S3 / WebDAV / 网盘).
admin.GET("/storage/status", listStorageConfigsHandler(svc))
admin.GET("/storage/:type", getStorageConfigHandler(svc))
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
admin.GET("/cloud/:type/list", cloudListHandler(svc))
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
// Download client CRUD.
admin.GET("/download/clients", listDownloadClientsHandler(svc))
admin.POST("/download/clients", createDownloadClientHandler(svc))
+119
View File
@@ -0,0 +1,119 @@
// Package cloud implements pluggable cloud-disk (网盘) providers used by the
// external-storage subsystem to expose remote files as playable media via
// HTTP 302 redirects.
//
// The design mirrors how Alist / MoviePilot offload playback: instead of the
// host downloading and re-streaming bytes, a provider resolves a file to a
// short-lived direct download URL and the player is 302-redirected straight to
// the cloud CDN. The host only performs a tiny redirect, freeing its CPU and
// bandwidth.
//
// Each provider authenticates with a cookie (obtained via the web UI, an API
// cookie, or a QR-code login flow). Providers are intentionally side-effect
// free and take an *http.Client so they can be exercised against httptest
// mock servers in unit tests.
package cloud
import (
"context"
"errors"
"net/http"
"strings"
"time"
)
// timeNow is a seam so tests can pin timestamps.
var timeNow = time.Now
// Provider types recognised by the registry.
const (
TypeQuark = "quark" // 夸克网盘
Type115 = "cloud115" // 115 网盘
)
// ErrUnsupported is returned for an unknown provider type.
var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing.
type FileEntry struct {
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
// PickCode is 115-specific; quark uses ID directly.
PickCode string `json:"pick_code,omitempty"`
}
// DirectLink is a resolved playback target.
type DirectLink struct {
URL string `json:"url"`
// Headers that must accompany a request to URL (e.g. User-Agent, Cookie).
Headers map[string]string `json:"-"`
// Proxy reports whether URL requires the host to reverse-proxy the bytes
// (because the headers cannot be carried by a plain browser 302). When
// false the play handler issues a pure 302 redirect (true offload).
Proxy bool `json:"-"`
}
// Provider is the common cloud-disk interface.
type Provider interface {
// Type returns the provider key (TypeQuark / Type115).
Type() string
// Ping validates the stored credentials (cookie). Cheap, used by the
// storage-config Test() probe.
Ping(ctx context.Context) error
// List returns the entries under dirID. An empty dirID means the root.
List(ctx context.Context, dirID string) ([]FileEntry, error)
// Resolve turns a provider-native file reference (id or pickcode) into a
// short-lived direct download link suitable for 302 playback.
Resolve(ctx context.Context, fileRef string) (*DirectLink, error)
}
// New constructs a provider of the given type from a free-form config map
// (as persisted by StorageConfigService). The client is shared so callers can
// inject timeouts / test transports.
func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) {
if client == nil {
client = http.DefaultClient
}
switch typ {
case TypeQuark:
return newQuark(cfg, client), nil
case Type115:
return new115(cfg, client), nil
default:
return nil, ErrUnsupported
}
}
// IsCloudType reports whether typ is a cloud-disk provider.
func IsCloudType(typ string) bool {
return typ == TypeQuark || typ == Type115
}
// str coerces a config value to a trimmed string.
func str(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
// boolish coerces a config value to bool ("true"/"1"/true → true).
func boolish(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
s := strings.ToLower(strings.TrimSpace(t))
return s == "1" || s == "true" || s == "yes" || s == "on"
default:
return false
}
}
// defaultUA is a desktop browser UA accepted by both 115 and quark.
const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+204
View File
@@ -0,0 +1,204 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQuarkListAndResolve(t *testing.T) {
var gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCookie = r.Header.Get("Cookie")
switch {
case r.URL.Path == "/file/sort":
if r.URL.Query().Get("pdir_fid") != "0" {
t.Errorf("unexpected pdir_fid %q", r.URL.Query().Get("pdir_fid"))
}
w.Write([]byte(`{"status":200,"code":0,"data":{"list":[
{"fid":"d1","file_name":"Movies","dir":true,"size":0},
{"fid":"f1","file_name":"Inception.mkv","dir":false,"size":123}]}}`))
case r.URL.Path == "/file/download":
if r.Method != http.MethodPost {
t.Errorf("download must be POST, got %s", r.Method)
}
w.Write([]byte(`{"status":200,"code":0,"data":[{"fid":"f1","download_url":"https://cdn.quark/x.mkv?sign=1"}]}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeQuark, map[string]any{"cookie": "kps=abc", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 || !entries[0].IsDir || entries[1].Name != "Inception.mkv" || entries[1].Size != 123 {
t.Fatalf("unexpected entries: %#v", entries)
}
if gotCookie != "kps=abc" {
t.Fatalf("cookie not forwarded: %q", gotCookie)
}
link, err := p.Resolve(context.Background(), "f1")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.quark/x.mkv?sign=1" {
t.Fatalf("bad url: %s", link.URL)
}
if !link.Proxy {
t.Fatalf("quark should default to proxy mode")
}
if link.Headers["Cookie"] != "kps=abc" {
t.Fatalf("resolve must carry cookie header: %#v", link.Headers)
}
}
func TestQuarkForce302(t *testing.T) {
p := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
if p.proxy {
t.Fatalf("force_302 should disable proxy mode")
}
}
func Test115ListAndResolve(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/files":
if r.URL.Query().Get("cid") != "0" {
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
}
w.Write([]byte(`{"state":true,"data":[
{"cid":"100","n":"Movies","s":0},
{"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`))
case "/files/download":
if r.URL.Query().Get("pickcode") != "pick200" {
t.Errorf("bad pickcode %q", r.URL.Query().Get("pickcode"))
}
w.Write([]byte(`{"state":true,"file_url":"https://cdn.115/x.mkv?t=1"}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries: %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "100" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), "pick200")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.115/x.mkv?t=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy {
t.Fatalf("115 should default to 302 (no proxy)")
}
}
func Test115QRFlow(t *testing.T) {
// status sequence: waiting → scanned → confirmed
calls := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/1.0/web/1.0/token/":
w.Write([]byte(`{"state":1,"data":{"uid":"U1","time":1700,"sign":"S1"}}`))
case "/get/status/":
if r.URL.Query().Get("uid") != "U1" {
t.Errorf("bad uid %q", r.URL.Query().Get("uid"))
}
calls++
switch calls {
case 1:
w.Write([]byte(`{"state":1,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":1,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":1,"data":{"status":2}}`))
}
default:
t.Errorf("unexpected api path %s", r.URL.Path)
}
}))
defer api.Close()
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app/1.0/web/1.0/login/qrcode/" {
t.Errorf("unexpected passport path %s", r.URL.Path)
}
w.Write([]byte(`{"state":1,"data":{"cookie":{"UID":"u","CID":"c","SEID":"s"}}}`))
}))
defer passport.Close()
oldA, oldP := qr115APIBase, qr115PassportBase
qr115APIBase, qr115PassportBase = api.URL, passport.URL
defer func() { qr115APIBase, qr115PassportBase = oldA, oldP }()
ctx := context.Background()
sess, err := QRStart(ctx, api.Client())
if err != nil {
t.Fatalf("qr start: %v", err)
}
if sess.UID != "U1" || sess.QRImageURL == "" {
t.Fatalf("bad session: %#v", sess)
}
want := []string{"waiting", "scanned", "confirmed"}
for i, exp := range want {
st, err := QRPoll(ctx, api.Client(), sess)
if err != nil {
t.Fatalf("poll %d: %v", i, err)
}
if st.State != exp {
t.Fatalf("poll %d: want %s got %s", i, exp, st.State)
}
if exp == "confirmed" {
if st.Cookie == "" || !containsAll(st.Cookie, "UID=u", "SEID=s") {
t.Fatalf("confirmed must yield cookie: %q", st.Cookie)
}
}
}
}
func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
found := false
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
found = true
break
}
}
if !found {
return false
}
}
return true
}
var _ = time.Second
+303
View File
@@ -0,0 +1,303 @@
package cloud
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
// pan115Provider implements 115 网盘 via cookie auth.
//
// 115 has removed its desktop clients, so cookies must come from the mobile
// app / web (115.com) or a QR-code login (see QR* helpers below). Directory
// listing uses the public web API; download resolves a file's pickcode to a
// CDN URL that, like Alist's default 115 behaviour, is served by 302 redirect.
type pan115Provider struct {
cookie string
ua string
webBase string // https://webapi.115.com (override in tests)
client *http.Client
proxy bool
}
const pan115WebBase = "https://webapi.115.com"
func new115(cfg map[string]any, client *http.Client) *pan115Provider {
web := str(cfg["base"])
if web == "" {
web = pan115WebBase
}
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
// 115 CDN download URLs work with a plain 302 (Alist's recommended mode),
// so offload by default; admin can force proxy mode if their network needs it.
proxy := false
if _, ok := cfg["force_proxy"]; ok && boolish(cfg["force_proxy"]) {
proxy = true
}
return &pan115Provider{
cookie: str(cfg["cookie"]),
ua: ua,
webBase: strings.TrimRight(web, "/"),
client: client,
proxy: proxy,
}
}
func (p *pan115Provider) Type() string { return Type115 }
func (p *pan115Provider) get(ctx context.Context, u string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Cookie", p.cookie)
req.Header.Set("User-Agent", p.ua)
req.Header.Set("Accept", "application/json, text/plain, */*")
return p.client.Do(req)
}
func (p *pan115Provider) Ping(ctx context.Context) error {
if p.cookie == "" {
return fmt.Errorf("115: missing cookie")
}
_, err := p.List(ctx, "0")
return err
}
func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
if dirID == "" {
dirID = "0"
}
q := url.Values{}
q.Set("aid", "1")
q.Set("cid", dirID)
q.Set("o", "user_ptime")
q.Set("asc", "0")
q.Set("offset", "0")
q.Set("show_dir", "1")
q.Set("limit", "100")
q.Set("format", "json")
resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode())
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
State bool `json:"state"`
Error string `json:"error"`
Data []struct {
Fid string `json:"fid"` // file id (files only)
Cid string `json:"cid"` // category id (dirs use this)
N string `json:"n"` // name
S json.Number `json:"s"` // size
Pc string `json:"pc"` // pickcode
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("115: decode list: %w", err)
}
if !r.State {
return nil, fmt.Errorf("115: list failed: %s", r.Error)
}
out := make([]FileEntry, 0, len(r.Data))
for _, it := range r.Data {
isDir := it.Fid == ""
id := it.Fid
if isDir {
id = it.Cid
}
size, _ := it.S.Int64()
out = append(out, FileEntry{
ID: id,
Name: it.N,
IsDir: isDir,
Size: size,
PickCode: it.Pc,
})
}
return out, nil
}
// Resolve accepts a pickcode (preferred) and returns the CDN download URL.
func (p *pan115Provider) Resolve(ctx context.Context, pickcode string) (*DirectLink, error) {
if pickcode == "" {
return nil, fmt.Errorf("115: empty pickcode")
}
u := fmt.Sprintf("%s/files/download?pickcode=%s&_=%d", p.webBase, url.QueryEscape(pickcode), nowUnix())
resp, err := p.get(ctx, u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
State bool `json:"state"`
Error string `json:"error"`
FileURL string `json:"file_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("115: decode download: %w", err)
}
if !r.State || r.FileURL == "" {
msg := r.Error
if msg == "" {
msg = "no file_url"
}
return nil, fmt.Errorf("115: download failed: %s", msg)
}
return &DirectLink{
URL: r.FileURL,
Headers: map[string]string{
"User-Agent": p.ua,
"Cookie": p.cookie,
},
Proxy: p.proxy,
}, nil
}
// nowUnix is a seam for deterministic tests.
var nowUnix = func() int64 { return timeNow().Unix() }
// ─── QR-code login ───────────────────────────────────────────────────────────
// QRSession is the handle returned by QRStart; the client renders QRImageURL
// and polls QRPoll until it returns a cookie.
type QRSession struct {
UID string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
QRImageURL string `json:"qr_image_url"`
}
// QR login hosts (overridable for tests).
var (
qr115APIBase = "https://qrcodeapi.115.com"
qr115PassportBase = "https://passportapi.115.com"
)
// QRStart obtains a 115 QR-code login token + image URL.
func QRStart(ctx context.Context, client *http.Client) (*QRSession, error) {
if client == nil {
client = http.DefaultClient
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/api/1.0/web/1.0/token/", nil)
req.Header.Set("User-Agent", defaultUA)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
State int `json:"state"`
Data struct {
UID string `json:"uid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("115 qr: decode token: %w", err)
}
if r.State != 1 || r.Data.UID == "" {
return nil, fmt.Errorf("115 qr: token request failed")
}
return &QRSession{
UID: r.Data.UID,
Time: r.Data.Time,
Sign: r.Data.Sign,
QRImageURL: qr115APIBase + "/api/1.0/web/1.0/qrcode?uid=" + url.QueryEscape(r.Data.UID),
}, nil
}
// QRStatus is the poll result.
type QRStatus struct {
// State is one of: "waiting" (not scanned), "scanned" (scanned, awaiting
// confirmation), "confirmed" (login approved; Cookie populated),
// "expired" (token expired/cancelled).
State string `json:"state"`
Cookie string `json:"cookie,omitempty"`
}
// QRPoll checks the QR session status; on confirmation it exchanges the token
// for a session cookie via the passport API.
func QRPoll(ctx context.Context, client *http.Client, sess *QRSession) (*QRStatus, error) {
if client == nil {
client = http.DefaultClient
}
if sess == nil || sess.UID == "" {
return nil, fmt.Errorf("115 qr: nil session")
}
q := url.Values{}
q.Set("uid", sess.UID)
q.Set("time", strconv.FormatInt(sess.Time, 10))
q.Set("sign", sess.Sign)
q.Set("_", strconv.FormatInt(timeNow().UnixMilli(), 10))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/get/status/?"+q.Encode(), nil)
req.Header.Set("User-Agent", defaultUA)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
State int `json:"state"`
Data struct {
Status int `json:"status"` // 0 waiting, 1 scanned, 2 confirmed, -1/-2 expired
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("115 qr: decode status: %w", err)
}
switch r.Data.Status {
case 1:
return &QRStatus{State: "scanned"}, nil
case 2:
cookie, err := qr115Exchange(ctx, client, sess.UID)
if err != nil {
return nil, err
}
return &QRStatus{State: "confirmed", Cookie: cookie}, nil
case 0:
return &QRStatus{State: "waiting"}, nil
default:
return &QRStatus{State: "expired"}, nil
}
}
// qr115Exchange swaps an approved uid for a session cookie.
func qr115Exchange(ctx context.Context, client *http.Client, uid string) (string, error) {
form := url.Values{}
form.Set("account", uid)
form.Set("app", "web")
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, qr115PassportBase+"/app/1.0/web/1.0/login/qrcode/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", defaultUA)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var r struct {
State int `json:"state"`
Data struct {
Cookie map[string]string `json:"cookie"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return "", fmt.Errorf("115 qr: decode login: %w", err)
}
if r.State != 1 || len(r.Data.Cookie) == 0 {
return "", fmt.Errorf("115 qr: login exchange failed")
}
parts := make([]string, 0, len(r.Data.Cookie))
for k, v := range r.Data.Cookie {
parts = append(parts, k+"="+v)
}
return strings.Join(parts, "; "), nil
}
+160
View File
@@ -0,0 +1,160 @@
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
}
+113 -1
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// StorageConfigService encrypts + persists external storage configs.
@@ -182,14 +184,124 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
}
defer resp.Body.Close()
return nil
case cloud.TypeQuark, cloud.Type115:
p, err := cloud.New(in.Type, cfg, s.client)
if err != nil {
return err
}
return p.Ping(ctx)
default:
return fmt.Errorf("unsupported storage type %q", in.Type)
}
}
// CloudProvider constructs a cloud-disk provider from the saved (decrypted)
// config for the given type, or returns an error if not configured.
func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (cloud.Provider, error) {
if !cloud.IsCloudType(typ) {
return nil, fmt.Errorf("not a cloud provider: %q", typ)
}
view, err := s.Get(ctx, typ)
if err != nil {
return nil, err
}
if view == nil {
return nil, fmt.Errorf("%s storage not configured", typ)
}
return cloud.New(typ, view.Config, s.client)
}
// CloudList lists entries under dirID for the configured cloud provider.
func (s *StorageConfigService) CloudList(ctx context.Context, typ, dirID string) ([]cloud.FileEntry, error) {
p, err := s.CloudProvider(ctx, typ)
if err != nil {
return nil, err
}
return p.List(ctx, dirID)
}
// CloudResolve resolves a cloud file reference to a direct link.
func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef string) (*cloud.DirectLink, error) {
p, err := s.CloudProvider(ctx, typ)
if err != nil {
return nil, err
}
return p.Resolve(ctx, fileRef)
}
// cloudLibraryName maps a provider type to a friendly Chinese library name.
func cloudLibraryName(typ string) string {
switch typ {
case cloud.TypeQuark:
return "夸克网盘"
case cloud.Type115:
return "115 网盘"
default:
return typ
}
}
// ensureCloudLibrary returns (creating if necessary) the per-provider cloud
// library that owns imported 302 media.
func (s *StorageConfigService) ensureCloudLibrary(ctx context.Context, typ string) (*model.Library, error) {
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
path := "cloud://" + typ
for i := range libs {
if libs[i].Path == path {
return &libs[i], nil
}
}
lib := &model.Library{Name: cloudLibraryName(typ), Path: path, Type: "movie", Enabled: true}
if err := s.repo.Library.Create(ctx, lib); err != nil {
return nil, err
}
return lib, nil
}
// CloudImport creates (or refreshes) a playable media row backed by a cloud
// file. Playback is served entirely via 302 redirect — the host never streams
// the bytes (unless the provider requires proxy mode).
func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, name string, size int64) (*model.Media, error) {
if !cloud.IsCloudType(typ) {
return nil, fmt.Errorf("not a cloud provider: %q", typ)
}
if strings.TrimSpace(fileRef) == "" {
return nil, errors.New("file reference required")
}
lib, err := s.ensureCloudLibrary(ctx, typ)
if err != nil {
return nil, err
}
title := strings.TrimSpace(name)
container := ""
if i := strings.LastIndex(title, "."); i > 0 {
container = strings.ToLower(strings.TrimPrefix(title[i:], "."))
title = title[:i]
}
if title == "" {
title = fileRef
}
m := &model.Media{
LibraryID: lib.ID,
Title: title,
Path: "cloud://" + typ + "/" + fileRef,
SizeBytes: size,
Container: container,
STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(fileRef),
ScrapeStatus: "pending",
}
if err := s.repo.Media.Upsert(ctx, m); err != nil {
return nil, err
}
return m, nil
}
func validStorageType(t string) bool {
switch t {
case "alist", "s3", "webdav":
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115:
return true
}
return false
+42 -1
View File
@@ -1,6 +1,26 @@
import { api } from './client'
export type StorageType = 'alist' | 's3' | 'webdav'
export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark'
export interface CloudEntry {
id: string
name: string
is_dir: boolean
size: number
pick_code?: string
}
export interface QRSession {
uid: string
time: number
sign: string
qr_image_url: string
}
export interface QRStatus {
state: 'waiting' | 'scanned' | 'confirmed' | 'expired'
cookie?: string
}
export interface StorageConfig {
id: string
@@ -34,3 +54,24 @@ export const storageAPI = {
})
.then((r) => r.data),
}
// cloudAPI drives 网盘 browsing, QR login and 302 import.
export const cloudAPI = {
list: (type: StorageType, dir = '') =>
api
.get<{ items: CloudEntry[]; error?: string }>(`/admin/cloud/${type}/list`, {
params: { dir },
})
.then((r) => r.data),
import: (type: StorageType, ref: string, name: string, size: number) =>
api
.post(`/admin/cloud/${type}/import`, { ref, name, size })
.then((r) => r.data),
qrStart: (type: StorageType) =>
api.post<QRSession>(`/admin/cloud/${type}/qr/start`).then((r) => r.data),
qrPoll: (type: StorageType, sess: QRSession) =>
api.post<QRStatus>(`/admin/cloud/${type}/qr/poll`, sess).then((r) => r.data),
}
+199 -6
View File
@@ -1,8 +1,24 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Cloud, Loader2, Save, Send } from 'lucide-react'
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send } from 'lucide-react'
import toast from 'react-hot-toast'
import { storageAPI, type StorageType } from '../api/storage_config'
import {
cloudAPI,
storageAPI,
type CloudEntry,
type QRSession,
type StorageType,
} from '../api/storage_config'
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark']
const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t)
const TYPE_LABEL: Record<string, string> = {
alist: 'ALIST',
webdav: 'WEBDAV',
s3: 'S3',
cloud115: '115网盘',
quark: '夸克网盘',
}
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
// the import / playback / STRM subsystems. Mirrors the Vue UI's
@@ -18,24 +34,24 @@ export function StorageConfigPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
<p className="text-sm text-ink-50">
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
配置 Alist / S3 / WebDAV / 网盘(115 / 夸克)后端,Cookie 加密存储 + 在线测试,网盘资源通过 302 直链播放
</p>
</div>
</div>
<div className="flex gap-2 border-b border-gray-200">
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
{(['alist', 'webdav', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
<button
key={t}
onClick={() => setActive(t)}
className={
'border-b-2 px-4 py-2 text-sm uppercase ' +
'border-b-2 px-4 py-2 text-sm ' +
(active === t
? 'border-primary-400 text-brand-500'
: 'border-transparent text-ink-50 hover:text-white')
}
>
{t}
{TYPE_LABEL[t] ?? t}
</button>
))}
</div>
@@ -63,6 +79,14 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'secret_key', label: 'Secret Key', secret: true },
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
],
cloud115: [
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
{ key: 'force_proxy', label: '强制反代(true/false,默认 302 直链)' },
],
quark: [
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
}
function StorageForm({ type }: { type: StorageType }) {
@@ -158,6 +182,12 @@ function StorageForm({ type }: { type: StorageType }) {
/>
</label>
))}
{type === 'cloud115' && (
<QRLoginPanel
type={type}
onCookie={(c) => setConfig((cfg) => ({ ...cfg, cookie: c }))}
/>
)}
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
@@ -182,6 +212,169 @@ function StorageForm({ type }: { type: StorageType }) {
保存
</button>
</div>
{isCloud(type) && <CloudBrowser type={type} />}
</form>
)
}
// QRLoginPanel drives the 115 QR-code login: start → render image → poll →
// fill the cookie field on confirmation.
function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: string) => void }) {
const [sess, setSess] = useState<QRSession | null>(null)
const [state, setState] = useState<string>('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!sess) return
let alive = true
const timer = setInterval(async () => {
try {
const st = await cloudAPI.qrPoll(type, sess)
if (!alive) return
setState(st.state)
if (st.state === 'confirmed' && st.cookie) {
onCookie(st.cookie)
toast.success('扫码登录成功,Cookie 已填入,请点击保存')
setSess(null)
} else if (st.state === 'expired') {
toast.error('二维码已过期,请重新获取')
setSess(null)
}
} catch {
/* keep polling */
}
}, 2000)
return () => {
alive = false
clearInterval(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sess])
const start = async () => {
setBusy(true)
try {
const s = await cloudAPI.qrStart(type)
setSess(s)
setState('waiting')
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败'
toast.error(msg)
} finally {
setBusy(false)
}
}
return (
<div className="rounded-lg border border-gray-200 p-3">
<button
type="button"
onClick={start}
disabled={busy}
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <QrCode size={14} />}
使用 115 App 扫码登录
</button>
{sess && (
<div className="mt-3 flex items-center gap-3">
<img src={sess.qr_image_url} alt="115 QR" className="h-40 w-40 rounded bg-white p-1" />
<span className="text-sm text-ink-50">
{state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
</span>
</div>
)}
</div>
)
}
// CloudBrowser lists 网盘 directories and imports a file as a 302-backed media.
function CloudBrowser({ type }: { type: StorageType }) {
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
const [items, setItems] = useState<CloudEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const cur = stack[stack.length - 1]
const load = async (dir: string) => {
setLoading(true)
setError('')
try {
const r = await cloudAPI.list(type, dir)
setItems(r.items ?? [])
if (r.error) setError(r.error)
} catch (err: unknown) {
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败')
setItems([])
} finally {
setLoading(false)
}
}
useEffect(() => {
load(cur.id).catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stack.length, type])
const enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }])
const goTo = (i: number) => setStack((s) => s.slice(0, i + 1))
const doImport = async (e: CloudEntry) => {
const ref = type === 'cloud115' ? e.pick_code || e.id : e.id
try {
await cloudAPI.import(type, ref, e.name, e.size)
toast.success(`已导入「${e.name}」,可在媒体库中 302 播放`)
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
}
}
return (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
<div className="mb-2 flex flex-wrap items-center gap-1 text-xs text-ink-50">
<span className="text-ink-100">网盘资源:</span>
{stack.map((s, i) => (
<span key={i}>
<button type="button" className="hover:text-brand-500" onClick={() => goTo(i)}>
{s.name}
</button>
{i < stack.length - 1 && <span className="mx-1">/</span>}
</span>
))}
</div>
{loading ? (
<div className="flex justify-center py-4 text-ink-50">
<Loader2 className="animate-spin" size={16} />
</div>
) : error ? (
<p className="py-2 text-sm text-red-400">{error}(请先填写有效 Cookie 并保存)</p>
) : items.length === 0 ? (
<p className="py-2 text-sm text-ink-50">该目录为空</p>
) : (
<ul className="divide-y divide-gray-100">
{items.map((e) => (
<li key={e.id} className="flex items-center gap-2 py-1.5 text-sm">
{e.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
{e.is_dir ? (
<button type="button" className="flex-1 text-left text-ink-100 hover:text-brand-500" onClick={() => enter(e)}>
{e.name}
</button>
) : (
<>
<span className="flex-1 truncate text-ink-100">{e.name}</span>
<button
type="button"
className="rounded border border-gray-200 px-2 py-0.5 text-xs text-ink-100 hover:bg-gray-50"
onClick={() => doImport(e)}
>
导入
</button>
</>
)}
</li>
))}
</ul>
)}
</div>
)
}