diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go new file mode 100644 index 0000000..bdf8245 --- /dev/null +++ b/internal/handler/cloud.go @@ -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) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ef076d4..09dff24 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go new file mode 100644 index 0000000..17a3b05 --- /dev/null +++ b/internal/service/cloud/cloud.go @@ -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" diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go new file mode 100644 index 0000000..68e842e --- /dev/null +++ b/internal/service/cloud/cloud_test.go @@ -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 diff --git a/internal/service/cloud/pan115.go b/internal/service/cloud/pan115.go new file mode 100644 index 0000000..b85aa13 --- /dev/null +++ b/internal/service/cloud/pan115.go @@ -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 +} diff --git a/internal/service/cloud/quark.go b/internal/service/cloud/quark.go new file mode 100644 index 0000000..b498998 --- /dev/null +++ b/internal/service/cloud/quark.go @@ -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 +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index 03ee67b..c933525 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -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 diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index b3aade0..0008641 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -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(`/admin/cloud/${type}/qr/start`).then((r) => r.data), + + qrPoll: (type: StorageType, sess: QRSession) => + api.post(`/admin/cloud/${type}/qr/poll`, sess).then((r) => r.data), +} diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index 07f4768..47f3db6 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -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 = { + 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() {

外部存储

- 配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试 + 配置 Alist / S3 / WebDAV / 网盘(115 / 夸克)后端,Cookie 加密存储 + 在线测试,网盘资源通过 302 直链播放

- {(['alist', 'webdav', 's3'] as StorageType[]).map((t) => ( + {(['alist', 'webdav', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => ( ))}
@@ -63,6 +79,14 @@ const FIELD_DEFS: Record ))} + {type === 'cloud115' && ( + setConfig((cfg) => ({ ...cfg, cookie: c }))} + /> + )}