diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go deleted file mode 100644 index bdf8245..0000000 --- a/internal/handler/cloud.go +++ /dev/null @@ -1,130 +0,0 @@ -// 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 09dff24..ef076d4 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -94,10 +94,6 @@ 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)) @@ -309,18 +305,12 @@ 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 deleted file mode 100644 index 17a3b05..0000000 --- a/internal/service/cloud/cloud.go +++ /dev/null @@ -1,119 +0,0 @@ -// 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 deleted file mode 100644 index 68e842e..0000000 --- a/internal/service/cloud/cloud_test.go +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index b85aa13..0000000 --- a/internal/service/cloud/pan115.go +++ /dev/null @@ -1,303 +0,0 @@ -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 deleted file mode 100644 index b498998..0000000 --- a/internal/service/cloud/quark.go +++ /dev/null @@ -1,160 +0,0 @@ -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 c933525..03ee67b 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "strings" "time" @@ -19,7 +18,6 @@ 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. @@ -184,124 +182,14 @@ 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", cloud.TypeQuark, cloud.Type115: + case "alist", "s3", "webdav": return true } return false diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 0008641..b3aade0 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -1,26 +1,6 @@ import { api } from './client' -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 type StorageType = 'alist' | 's3' | 'webdav' export interface StorageConfig { id: string @@ -54,24 +34,3 @@ 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 47f3db6..07f4768 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -1,24 +1,8 @@ import { FormEvent, useEffect, useMemo, useState } from 'react' -import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send } from 'lucide-react' +import { Cloud, Loader2, Save, Send } from 'lucide-react' import toast from 'react-hot-toast' -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: '夸克网盘', -} +import { storageAPI, type StorageType } from '../api/storage_config' // StorageConfigPage manages the Alist / S3 / WebDAV adapters used by // the import / playback / STRM subsystems. Mirrors the Vue UI's @@ -34,24 +18,24 @@ export function StorageConfigPage() {

外部存储

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

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