mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user