添加strm生成

添加strm
This commit is contained in:
truewhile
2026-08-24 14:36:17 +08:00
parent cd1297a30f
commit c55d29ae84
47 changed files with 11956 additions and 4 deletions
+138
View File
@@ -0,0 +1,138 @@
// 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 offloads playback to the cloud provider: 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 (
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
)
// 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; other providers use 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.
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)
}
// MutableProvider is implemented by cloud bridges that support safe folder
// management through their official API or standard WebDAV methods.
type MutableProvider interface {
Provider
Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error)
Rename(ctx context.Context, ref, name string) (*FileEntry, error)
}
// MovableProvider is implemented by writable cloud bridges that can move an
// entry across directories, optionally renaming it in the same operation.
type MovableProvider interface {
MutableProvider
Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, 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 Type115:
// 115 使用开放平台(openapi)驱动(QMediaSync 同款:授权/列目录/直链)。
return NewOpenAPI115(str(cfg["app_id"]), str(cfg["access_token"]), str(cfg["refresh_token"])), nil
case TypeCloudDrive2:
return newCloudDrive2(cfg, client), nil
case TypeOpenList:
return newOpenList(cfg, client), nil
default:
return nil, ErrUnsupported
}
}
// IsCloudType reports whether typ is a cloud-disk provider.
func IsCloudType(typ string) bool {
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
}
// 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 upstream cloud providers.
const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+134
View File
@@ -0,0 +1,134 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// 115 开放平台(openapi)驱动测试:mock proapi 的列目录/直链接口。
func newOpenAPI115TestProvider(t *testing.T, handler http.HandlerFunc) (*openAPI115Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
old := cloud115.ProAPIBase
cloud115.ProAPIBase = srv.URL
t.Cleanup(func() { cloud115.ProAPIBase = old; srv.Close() })
p := NewOpenAPI115("100195125", "token-abc", "refresh-xyz")
return p, srv
}
func Test115OpenAPIListAndResolve(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
if r.Header.Get("Authorization") != "Bearer token-abc" {
t.Errorf("missing authorization header")
}
if r.URL.Query().Get("cid") != "0" {
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
}
w.Write([]byte(`{"state":true,"data":[
{"fid":"100","cid":"100","fc":"0","fn":"Movies","fs":0,"pc":""},
{"fid":"200","fc":"1","fn":"Inception.mkv","fs":456,"pc":"pick200"}]}`))
case "/open/ufile/downurl":
if r.Header.Get("Authorization") != "Bearer token-abc" {
t.Errorf("missing authorization header")
}
_ = r.ParseForm()
if r.PostFormValue("pick_code") != "pick200" {
t.Errorf("bad pick_code %q", r.PostFormValue("pick_code"))
}
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"Inception.mkv","url":{"url":"https://cdn.115/x.mkv?t=1"}}}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
entries, err := p.List(context.Background(), "0")
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" || entries[0].Name != "Movies" {
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 openapi should default to 302 (no proxy)")
}
}
func Test115OpenAPIListPaginates(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
count := 100
if offset > 0 {
count = 1
}
items := make([]string, 0, count)
for i := 0; i < count; i++ {
n := offset + i
items = append(items, `{"fid":"`+strconv.Itoa(n)+`","fc":"1","fn":"Movie.`+padZero(n)+`.mkv","fs":1,"pc":"pick`+strconv.Itoa(n)+`"}`)
}
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
})
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 101 {
t.Fatalf("entries = %d, want 101", len(entries))
}
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
t.Fatalf("last entry wrong: %#v", entries[100])
}
}
func Test115OpenAPIErrorSurfaced(t *testing.T) {
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/downurl" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(`{"state":false,"message":"文件不存在"}`))
})
_, err := p.Resolve(context.Background(), "pickX")
if err == nil || !strings.Contains(err.Error(), "文件不存在") {
t.Fatalf("want upstream error surfaced, got %v", err)
}
}
func Test115OpenAPIPingRequiresToken(t *testing.T) {
p := NewOpenAPI115("100195125", "", "")
if err := p.Ping(context.Background()); err == nil {
t.Fatalf("ping without token should fail")
}
}
func padZero(n int) string {
s := strconv.Itoa(n)
for len(s) < 3 {
s = "0" + s
}
return s
}
+25
View File
@@ -0,0 +1,25 @@
package cloud
import (
"net/http"
"testing"
)
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient)
if !cd2.proxy {
t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated")
}
}
func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
}
if _, err := New("quark", nil, nil); err != ErrUnsupported {
t.Fatalf("quark should be unsupported, got %v", err)
}
if IsCloudType("quark") {
t.Fatal("quark should not be an active cloud provider")
}
}
+237
View File
@@ -0,0 +1,237 @@
package cloud
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"path"
"strings"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
//
// CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more).
// Treating it as a WebDAV-backed cloud provider lets MediaStationGo
// browse, mount and upload to those disks without carrying every provider's
// private chunk-upload protocol in this project.
type cloudDrive2Provider struct {
typ string
name string
base *url.URL
username string
password string
token string
ua string
apiBase *url.URL
client *http.Client
proxy bool
}
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav")
}
func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav")
}
func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider {
rawURL := webDAVURLFromConfig(cfg, defaultDAVPath)
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
var apiBase *url.URL
if typ == TypeOpenList {
apiBase = openListAPIBaseFromConfig(cfg, rawURL, defaultDAVPath)
}
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
proxy := true
return &cloudDrive2Provider{
typ: typ,
name: name,
base: u,
username: str(cfg["username"]),
password: str(cfg["password"]),
token: str(cfg["token"]),
ua: ua,
apiBase: apiBase,
client: client,
proxy: proxy,
}
}
func (p *cloudDrive2Provider) Type() string { return p.typ }
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
_, err := p.List(ctx, "")
return err
}
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if err := p.validate(); err != nil {
return nil, err
}
ref := normalizeCloudDAVPath(fileRef)
if ref == "/" {
return nil, fmt.Errorf("%s: file reference required", p.name)
}
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
if p.apiBase == nil {
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
}
link, err := p.resolveOpenListAPIDirect(ctx, ref)
if err != nil {
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
}
return link, nil
}
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
if err != nil {
return nil, fmt.Errorf("%s: pure 302 playback requires CloudDrive2/WebDAV to return a CDN Location for %s: %w", p.name, ref, err)
}
return link, nil
}
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
}
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return fmt.Errorf("%s: missing WebDAV URL", p.name)
}
return nil
}
func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string {
rawURL := str(cfg["url"])
if rawURL == "" {
rawURL = str(cfg["webdav_url"])
}
if rawURL != "" {
return ensureDefaultDAVPath(rawURL, defaultDAVPath)
}
return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath)
}
func defaultWebDAVURL(server, defaultDAVPath string) string {
server = strings.TrimRight(strings.TrimSpace(server), "/")
if server == "" {
return ""
}
davPath := strings.TrimSpace(defaultDAVPath)
if davPath == "" {
return server
}
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
return server + davPath
}
func openListAPIBaseFromConfig(cfg map[string]any, webDAVURL, defaultDAVPath string) *url.URL {
raw := str(cfg["server"])
if raw == "" {
raw = firstNonEmpty(str(cfg["api_url"]), webDAVURL)
}
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil
}
davPath := strings.Trim(strings.TrimSpace(defaultDAVPath), "/")
if davPath != "" {
pathParts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
if len(pathParts) > 0 && strings.EqualFold(pathParts[len(pathParts)-1], davPath) {
u.Path = strings.Join(pathParts[:len(pathParts)-1], "/")
if u.Path == "" {
u.Path = "/"
}
}
}
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return u
}
func (p *cloudDrive2Provider) openListAPIURL(apiPath string) string {
if p.apiBase == nil {
return ""
}
u := *p.apiBase
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
apiPath = "/" + strings.TrimLeft(apiPath, "/")
if basePath == "" || basePath == "/" {
u.Path = apiPath
} else {
u.Path = basePath + apiPath
}
return u.String()
}
func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string {
rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/")
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return rawURL
}
if strings.TrimSpace(defaultDAVPath) == "" {
return rawURL
}
if u.Path == "" || u.Path == "/" {
davPath := strings.TrimSpace(defaultDAVPath)
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
u.Path = davPath
u.RawPath = ""
return strings.TrimRight(u.String(), "/")
}
return rawURL
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
if cleaned == "." {
return "/"
}
return cleaned
}
func sameCloudDAVPath(a, b string) bool {
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+172
View File
@@ -0,0 +1,172 @@
package cloud
import (
"context"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
)
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
if err := p.validate(); err != nil {
return nil, err
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.listOpenListAPI(ctx, dir)
}
target := normalizeCloudDAVPath(dir)
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
if err != nil {
return nil, err
}
p.auth(req)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.decorateDAVStatusError(resp, target)
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
var multi cloudDAVMultiStatus
if err := xml.Unmarshal(body, &multi); err != nil {
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
out := make([]FileEntry, 0, len(multi.Responses))
for _, item := range multi.Responses {
entryPath, err := p.entryIDFromHref(item.Href, basePath)
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
continue
}
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: entryPath,
Name: name,
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
})
}
return out, nil
}
func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
target := p.urlFor(fileRef)
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers)
if err != nil {
return nil, decorateDAVTransportError(p.name, target, err)
}
if location == "" {
return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status)
}
return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Range", "bytes=0-0")
if strings.TrimSpace(p.ua) != "" {
req.Header.Set("User-Agent", p.ua)
}
for key, value := range headers {
key = strings.TrimSpace(key)
if key != "" && strings.TrimSpace(value) != "" {
req.Header.Set(key, value)
}
}
client := p.client
if client == nil {
client = http.DefaultClient
}
noFollow := *client
noFollow.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := noFollow.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
status := resp.StatusCode
if status >= 300 && status < 400 {
rawLocation := strings.TrimSpace(resp.Header.Get("Location"))
if rawLocation == "" {
return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status)
}
location, err := resolveHTTPRedirectLocation(target, rawLocation)
if err != nil {
return "", status, err
}
return location, status, nil
}
return "", status, nil
}
func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) {
rawLocation = strings.TrimSpace(rawLocation)
if rawLocation == "" {
return "", fmt.Errorf("empty redirect Location")
}
if strings.HasPrefix(rawLocation, "//") {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" {
return "", fmt.Errorf("protocol-relative redirect Location without base scheme")
}
rawLocation = base.Scheme + ":" + rawLocation
}
location, err := url.Parse(rawLocation)
if err != nil {
return "", fmt.Errorf("invalid redirect Location: %w", err)
}
if location.IsAbs() {
if location.Scheme != "http" && location.Scheme != "https" {
return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme)
}
return location.String(), nil
}
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid redirect base URL: %w", err)
}
return base.ResolveReference(location).String(), nil
}
func (p *cloudDrive2Provider) auth(req *http.Request) {
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
return
}
if p.username != "" {
req.SetBasicAuth(p.username, p.password)
}
}
@@ -0,0 +1,55 @@
package cloud
import (
"fmt"
"io"
"net/http"
"strings"
)
func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
if resp.StatusCode == http.StatusMethodNotAllowed {
return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode)
}
return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode)
}
if resp.StatusCode == http.StatusMethodNotAllowed {
return fmt.Errorf("%s: list %s returned http %d:%s;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode, detail)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s: list %s returned http %d:%s;请检查 WebDAV 用户名/密码、Authorization Token,或先在 OpenList/CloudDrive2 中保存对应网盘 Cookie", p.name, target, resp.StatusCode, detail)
}
return fmt.Errorf("%s: list %s returned http %d:%s", p.name, target, resp.StatusCode, detail)
}
func compactDAVErrorBody(raw string) string {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\x00", ""))
if raw == "" {
return ""
}
raw = strings.Join(strings.Fields(raw), " ")
if len([]rune(raw)) > 180 {
return string([]rune(raw)[:180]) + "…"
}
return raw
}
func decorateDAVTransportError(name, target string, err error) error {
if err == nil {
return nil
}
message := err.Error()
if strings.Contains(message, "server gave HTTP response to HTTPS client") {
return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址,例如 OpenList 默认 WebDAV 通常是 http://host:5244/dav/;如果必须使用 https,请在 OpenList 前配置反向代理和证书", name, err)
}
if strings.Contains(message, "first record does not look like a TLS handshake") {
return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target)
}
return err
}
@@ -0,0 +1,85 @@
package cloud
import (
"net/url"
"strconv"
"strings"
)
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
u := *p.base
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/")
switch {
case basePath == "" || basePath == "/":
if remote == "" {
u.Path = "/"
} else {
u.Path = "/" + remote
}
case remote == "":
u.Path = basePath
default:
u.Path = basePath + "/" + remote
}
return u.String()
}
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
if href == "" {
return "", nil
}
parsed, err := url.Parse(href)
if err != nil {
return "", err
}
hrefPath := parsed.EscapedPath()
if hrefPath == "" {
hrefPath = href
}
if basePath != "" && basePath != "/" {
hrefPath = strings.TrimPrefix(hrefPath, basePath)
}
if decoded, err := url.PathUnescape(hrefPath); err == nil {
hrefPath = decoded
}
return normalizeCloudDAVPath(hrefPath), nil
}
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
type cloudDAVMultiStatus struct {
Responses []cloudDAVResponse `xml:"response"`
}
type cloudDAVResponse struct {
Href string `xml:"href"`
PropStat cloudDAVPropStat `xml:"propstat"`
}
type cloudDAVPropStat struct {
Prop cloudDAVProp `xml:"prop"`
}
type cloudDAVProp struct {
DisplayName string `xml:"displayname"`
ContentLength string `xml:"getcontentlength"`
ResourceType cloudDAVResourceType `xml:"resourcetype"`
}
type cloudDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
func parseDAVSize(raw string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return n
}
@@ -0,0 +1,312 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path"
"strings"
)
func (p *cloudDrive2Provider) Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
parent := normalizeCloudDAVPath(parentDir)
target := joinOpenListAPIPath(parent, cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Rename(ctx context.Context, ref, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot rename root directory", p.name)
}
target := joinOpenListAPIPath(path.Dir(source), cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIRename(ctx, source, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) {
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot move root directory", p.name)
}
cleanName := strings.TrimSpace(name)
if cleanName == "" {
cleanName = path.Base(source)
}
var err error
cleanName, err = cleanCloudEntryName(cleanName)
if err != nil {
return nil, err
}
targetDir = normalizeCloudDAVPath(targetDir)
target := joinOpenListAPIPath(targetDir, cleanName)
if sameCloudDAVPath(source, target) {
return &FileEntry{ID: target, Name: cleanName}, nil
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMove(ctx, source, targetDir, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
func cleanCloudEntryName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "", fmt.Errorf("entry name is required")
}
if strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("entry name cannot contain path separators")
}
return name, nil
}
func (p *cloudDrive2Provider) openListAPIMkdir(ctx context.Context, target string) error {
return p.openListAPIPost(ctx, "/api/fs/mkdir", map[string]string{"path": normalizeCloudDAVPath(target)}, "mkdir")
}
func (p *cloudDrive2Provider) openListAPIRename(ctx context.Context, source, name string) error {
return p.openListAPIPost(ctx, "/api/fs/rename", map[string]string{
"path": normalizeCloudDAVPath(source),
"name": name,
}, "rename")
}
func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targetDir, targetName string) error {
targetDir = normalizeCloudDAVPath(targetDir)
sourceName := path.Base(normalizeCloudDAVPath(source))
if sameCloudDAVPath(path.Dir(source), targetDir) {
if sourceName == targetName {
return nil
}
return p.openListAPIRename(ctx, source, targetName)
}
if err := p.openListAPIPost(ctx, "/api/fs/move", map[string]any{
"src_dir": normalizeCloudDAVPath(path.Dir(source)),
"dst_dir": targetDir,
"names": []string{sourceName},
}, "move"); err != nil {
return err
}
if sourceName != targetName {
moved := joinOpenListAPIPath(targetDir, sourceName)
return p.openListAPIRename(ctx, moved, targetName)
}
return nil
}
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode)
}
var decoded struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return fmt.Errorf("%s: decode api %s: %w", p.name, action, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return fmt.Errorf("%s: api %s failed: %s", p.name, action, msg)
}
return nil
}
// PutFile 把本地文件内容上传(覆盖)到远端 remotePath。
// OpenList 账号优先走 OpenList /api/fs/form 分片表单接口;其余走 WebDAV PUT。
func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r io.Reader) error {
target := normalizeCloudDAVPath(remotePath)
if target == "/" {
return fmt.Errorf("%s: cannot upload to root directory", p.name)
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.openListAPIPutFile(ctx, target, r)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.urlFor(target), r)
if err != nil {
return err
}
p.auth(req)
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
default:
return p.decorateDAVMutationStatusError(resp, "upload", target)
}
}
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
// PUT + multipart + File-Path 头)。
func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath string, r io.Reader) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
encodedPath := openListPathEscape(remotePath)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
if err != nil {
return err
}
if _, err := io.Copy(formFile, r); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), body)
if err != nil {
return err
}
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("File-Path", encodedPath)
req.Header.Set("As-Task", "true")
req.Header.Set("Overwrite", "true")
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/form"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return p.openListAPIStatusError("upload", remotePath, resp.StatusCode)
}
// OpenList 返回 code=200 即任务受理成功(小文件同步完成,大文件异步排队)。
return nil
}
// openListPathEscape 保留斜杠地 URL 编码远端路径(OpenList File-Path 需要)。
func openListPathEscape(p string) string {
parts := strings.Split(strings.TrimPrefix(normalizeCloudDAVPath(p), "/"), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func (p *cloudDrive2Provider) webDAVMkdir(ctx context.Context, target string) error {
req, err := http.NewRequestWithContext(ctx, "MKCOL", p.urlFor(target), nil)
if err != nil {
return err
}
p.auth(req)
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
case http.StatusMethodNotAllowed:
return fmt.Errorf("%s: mkdir %s returned http %d; the folder may already exist or this WebDAV backend is read-only", p.name, target, resp.StatusCode)
default:
return p.decorateDAVMutationStatusError(resp, "mkdir", target)
}
}
func (p *cloudDrive2Provider) webDAVRename(ctx context.Context, source, target string) error {
req, err := http.NewRequestWithContext(ctx, "MOVE", p.urlFor(source), nil)
if err != nil {
return err
}
p.auth(req)
req.Header.Set("Destination", p.webDAVDestination(target))
req.Header.Set("Overwrite", "F")
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(source), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
default:
return p.decorateDAVMutationStatusError(resp, "rename", source)
}
}
func (p *cloudDrive2Provider) webDAVDestination(target string) string {
raw := p.urlFor(target)
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
func (p *cloudDrive2Provider) decorateDAVMutationStatusError(resp *http.Response, action, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
return fmt.Errorf("%s: %s %s returned http %d", p.name, action, target, resp.StatusCode)
}
return fmt.Errorf("%s: %s %s returned http %d:%s", p.name, action, target, resp.StatusCode, detail)
}
@@ -0,0 +1,240 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
for pageNum := 1; ; pageNum++ {
payload := map[string]any{
"path": target,
"password": "",
"page": pageNum,
"per_page": pageSize,
"refresh": false,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err)
}
var decoded openListListResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("list", target, resp.StatusCode)
}
if decodeErr != nil {
return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg)
}
for _, item := range decoded.Data.Content {
name := strings.TrimSpace(item.Name)
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: joinOpenListAPIPath(target, name),
Name: name,
IsDir: item.IsDir,
Size: item.Size,
})
}
total := decoded.Data.Total
if total > 0 {
if len(out) >= total || len(decoded.Data.Content) == 0 {
break
}
continue
}
if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize {
break
}
}
return out, nil
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode)
}
var decoded openListGetResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("%s: decode api get: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg)
}
raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL)
if raw == "" {
return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef)
}
resolved, err := p.resolveOpenListPlaybackURL(raw)
if err != nil {
return nil, err
}
headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header)
if len(headers) > 0 {
return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ","))
}
resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved)
if err != nil {
return nil, err
}
return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) {
if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) {
return rawURL, nil
}
location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil)
if err != nil {
return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err)
}
if location != "" {
return location, nil
}
return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status)
}
func (p *cloudDrive2Provider) openListAPIStatusError(action, target string, status int) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status)
}
return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status)
}
func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
}
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
resp, err := p.client.Do(req)
if err != nil {
return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode)
}
var decoded openListLoginResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return "", fmt.Errorf("%s: decode api login: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return "", fmt.Errorf("%s: api login failed: %s", p.name, msg)
}
token := strings.TrimSpace(decoded.Data.Token)
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%s: empty playback URL", p.name)
}
if strings.HasPrefix(raw, "//") {
if p.apiBase == nil || p.apiBase.Scheme == "" {
return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name)
}
raw = p.apiBase.Scheme + ":" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err)
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme)
}
return u.String(), nil
}
if p.apiBase == nil {
return "", fmt.Errorf("%s: relative playback URL without API base", p.name)
}
base := *p.apiBase
base.RawPath = ""
base.RawQuery = ""
base.Fragment = ""
return base.ResolveReference(u).String(), nil
}
@@ -0,0 +1,126 @@
package cloud
import (
"encoding/json"
"net/url"
"path"
"sort"
"strings"
)
func sortedHeaderNames(headers map[string]string) []string {
if len(headers) == 0 {
return nil
}
out := make([]string, 0, len(headers))
for key := range headers {
key = strings.TrimSpace(key)
if key != "" {
out = append(out, key)
}
}
sort.Strings(out)
return out
}
func sameURLHost(raw string, base *url.URL) bool {
if base == nil {
return false
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
if !u.IsAbs() {
return true
}
return strings.EqualFold(u.Host, base.Host)
}
func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
key := strings.TrimSpace(k)
if key == "" {
continue
}
switch value := v.(type) {
case string:
if strings.TrimSpace(value) != "" {
out[key] = strings.TrimSpace(value)
}
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
if len(parts) > 0 {
out[key] = strings.Join(parts, ", ")
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func isCloudVideoPlaybackCandidate(fileRef string) bool {
switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) {
case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg":
return true
default:
return false
}
}
type openListListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Content []openListListItem `json:"content"`
Total int `json:"total"`
} `json:"data"`
}
type openListListItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
}
type openListGetResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RawURL string `json:"raw_url"`
URL string `json:"url"`
Header json.RawMessage `json:"header"`
} `json:"data"`
}
type openListLoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
func joinOpenListAPIPath(dir, name string) string {
dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/")
name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/")
if dir == "" || dir == "/" {
return normalizeCloudDAVPath(name)
}
return normalizeCloudDAVPath(dir + "/" + name)
}
+171
View File
@@ -0,0 +1,171 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
var gotAuth, gotDepth, gotRange string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
gotAuth = r.Header.Get("Authorization")
gotDepth = r.Header.Get("Depth")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/115/</d:href>
<d:propstat><d:prop><d:displayname>115</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/123/Movie.mkv</d:href>
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>789</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
gotAuth = r.Header.Get("Authorization")
gotRange = r.Header.Get("Range")
http.Redirect(w, r, "https://cdn.example.test/123/Movie.mkv?sign=1", http.StatusFound)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if gotDepth != "1" {
t.Fatalf("Depth = %q, want 1", gotDepth)
}
if !strings.HasPrefix(gotAuth, "Basic ") {
t.Fatalf("missing basic auth: %q", gotAuth)
}
if len(entries) != 2 {
t.Fatalf("entries = %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "/115" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), entries[1].ID)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy || len(link.Headers) != 0 {
t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link)
}
if gotRange != "bytes=0-0" {
t.Fatalf("resolve should probe with a tiny range, got %q", gotRange)
}
}
func TestCloudDrive2ResolveRejectsWebDAVProxyFallbackWithoutRedirect(t *testing.T) {
var getSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
getSeen = true
w.Header().Set("Content-Range", "bytes 0-0/10")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/123/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "without CDN Location") || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") {
t.Fatalf("resolve error = %v, want pure 302 refusal", err)
}
if !getSeen {
t.Fatal("expected CloudDrive2 WebDAV direct-link probe")
}
}
func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
var mkcolSeen bool
var destinations []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "MKCOL" && r.URL.Path == "/dav/TV":
mkcolSeen = true
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/TV":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/Inbox/Movie.mkv":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("clouddrive2 should support mutable provider")
}
if _, err := mutable.Mkdir(context.Background(), "", "TV"); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil {
t.Fatalf("rename: %v", err)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if !mkcolSeen || len(destinations) != 2 {
t.Fatalf("mkcol=%v destinations=%#v, want mkdir and two MOVE calls", mkcolSeen, destinations)
}
if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" {
t.Fatalf("rename Destination = %q", destinations[0])
}
if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" {
t.Fatalf("move Destination = %q", destinations[1])
}
if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
@@ -0,0 +1,280 @@
package cloud
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOpenListWebDAVListAndResolve(t *testing.T) {
var gotPath, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/auth/login" {
http.NotFound(w, r)
return
}
if r.URL.Path == "/api/fs/get" {
http.NotFound(w, r)
return
}
if r.Method != "PROPFIND" || r.URL.Path != "/dav" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
gotPath = r.URL.Path
gotDepth = r.Header.Get("Depth")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/Cloud/Movie.mkv</d:href>
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
if err != nil {
t.Fatal(err)
}
if p.Type() != TypeOpenList {
t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if gotPath != "/dav" {
t.Fatalf("path = %q, want /dav", gotPath)
}
if gotDepth != "1" {
t.Fatalf("Depth = %q, want 1", gotDepth)
}
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
t.Fatalf("entries = %#v", entries)
}
_, err = p.Resolve(context.Background(), entries[0].ID)
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
}
}
func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T) {
var loginSeen, listSeen, davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/login":
loginSeen = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode login body: %v", err)
}
if body["username"] != "alice" || body["password"] != "secret" {
t.Fatalf("login body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
case "/api/fs/list":
listSeen = true
if r.Header.Get("Authorization") != "api-token" {
t.Fatalf("Authorization = %q, want api-token", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"code":200,"data":{"content":[{"name":"Movies","is_dir":true,"size":0},{"name":"Movie.mkv","is_dir":false,"size":1024}],"total":2}}`))
case "/dav":
davSeen = true
w.WriteHeader(http.StatusMultiStatus)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if !loginSeen || !listSeen {
t.Fatalf("expected api login/list, login=%v list=%v", loginSeen, listSeen)
}
if davSeen {
t.Fatal("openlist API credentials should not fall back to WebDAV")
}
if len(entries) != 2 || entries[0].ID != "/Movies" || !entries[0].IsDir || entries[1].ID != "/Movie.mkv" || entries[1].Size != 1024 {
t.Fatalf("entries = %#v", entries)
}
}
func TestOpenListMutableProviderUsesAPI(t *testing.T) {
var mkdirPath, renamePath, renameName, moveSrcDir, moveDstDir string
var moveNames []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/fs/mkdir":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode mkdir body: %v", err)
}
mkdirPath = body["path"]
if r.Header.Get("Authorization") != "alist-token" {
t.Fatalf("mkdir Authorization = %q", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/rename":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode rename body: %v", err)
}
renamePath = body["path"]
renameName = body["name"]
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/move":
var body struct {
SrcDir string `json:"src_dir"`
DstDir string `json:"dst_dir"`
Names []string `json:"names"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode move body: %v", err)
}
moveSrcDir = body.SrcDir
moveDstDir = body.DstDir
moveNames = body.Names
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("openlist should support mutable provider")
}
created, err := mutable.Mkdir(context.Background(), "/电视剧", "欧美剧")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
if mkdirPath != "/电视剧/欧美剧" || created.ID != "/电视剧/欧美剧" || !created.IsDir {
t.Fatalf("mkdir path=%q entry=%#v", mkdirPath, created)
}
renamed, err := mutable.Rename(context.Background(), "/电视剧/欧美剧", "美剧")
if err != nil {
t.Fatalf("rename: %v", err)
}
if renamePath != "/电视剧/欧美剧" || renameName != "美剧" || renamed.ID != "/电视剧/美剧" {
t.Fatalf("rename path=%q name=%q entry=%#v", renamePath, renameName, renamed)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/待整理/Show.S01E01.mkv", "/动漫/国漫/Show/Season 01", "Show - S01E01.mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if moveSrcDir != "/待整理" || moveDstDir != "/动漫/国漫/Show/Season 01" || len(moveNames) != 1 || moveNames[0] != "Show.S01E01.mkv" {
t.Fatalf("move src=%q dst=%q names=%#v", moveSrcDir, moveDstDir, moveNames)
}
if renamePath != "/动漫/国漫/Show/Season 01/Show.S01E01.mkv" || renameName != "Show - S01E01.mkv" {
t.Fatalf("post-move rename path=%q name=%q", renamePath, renameName)
}
if moved.ID != "/动漫/国漫/Show/Season 01/Show - S01E01.mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
func TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) {
var davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/auth/login":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":500,"message":"bad password"}`))
case "/dav":
davSeen = true
w.WriteHeader(http.StatusMultiStatus)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "bad"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.List(context.Background(), "")
if err == nil || !strings.Contains(err.Error(), "api login failed") || !strings.Contains(err.Error(), "bad password") {
t.Fatalf("list error = %v, want api login failure", err)
}
if davSeen {
t.Fatal("openlist API failure fell back to WebDAV")
}
}
func TestOpenListRootURLDefaultsToDAV(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client())
if err != nil {
t.Fatal(err)
}
if _, err := p.List(context.Background(), ""); err != nil {
t.Fatalf("list: %v", err)
}
if gotPath != "/dav" {
t.Fatalf("path = %q, want /dav", gotPath)
}
}
func TestOpenListURLForKeepsNonASCIIPathSingleEncoded(t *testing.T) {
p := newOpenList(map[string]any{"url": "http://example.test:5244/dav/"}, nil)
got := p.urlFor("/动画电影/爱宠大机密2 (2019) {tmdb-412117}")
if strings.Contains(got, "%25E") {
t.Fatalf("url is double-escaped: %s", got)
}
want := "http://example.test:5244/dav/%E5%8A%A8%E7%94%BB%E7%94%B5%E5%BD%B1/%E7%88%B1%E5%AE%A0%E5%A4%A7%E6%9C%BA%E5%AF%862%20%282019%29%20%7Btmdb-412117%7D"
if got != want {
t.Fatalf("url = %s, want %s", got, want)
}
}
func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("请先填写有效Cookie并保存"))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.List(context.Background(), "")
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -0,0 +1,204 @@
package cloud
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if gotPath != "/api/fs/get" {
t.Fatalf("api path = %q, want /api/fs/get", gotPath)
}
if gotAuth != "alist-token" {
t.Fatalf("Authorization = %q, want token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" {
t.Fatalf("url = %q", link.URL)
}
if link.Proxy {
t.Fatalf("openlist raw_url without required headers should be 302 playback")
}
}
func TestOpenListResolveCollapsesHostedRawURLRedirectToCDN(t *testing.T) {
var probeSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
case "/d/Cloud/Movie.mkv":
probeSeen = true
if r.Header.Get("Range") != "bytes=0-0" {
t.Fatalf("probe Range = %q", r.Header.Get("Range"))
}
http.Redirect(w, r, "https://cdn.example.test/movie.mkv?sign=cdn", http.StatusFound)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !probeSeen {
t.Fatal("expected OpenList-hosted raw_url probe")
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=cdn" || link.Proxy || len(link.Headers) != 0 {
t.Fatalf("link = %#v, want collapsed CDN 302 playback", link)
}
}
func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) {
var loginSeen bool
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/login":
loginSeen = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode login body: %v", err)
}
if body["username"] != "alice" || body["password"] != "secret" {
t.Fatalf("login body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
case "/api/fs/get":
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !loginSeen {
t.Fatalf("expected api login before fs/get")
}
if gotAuth != "api-token" {
t.Fatalf("Authorization = %q, want api-token", gotAuth)
}
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy {
t.Fatalf("link = %#v, want raw_url 302 playback", link)
}
}
func TestOpenListResolveRejectsProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/fs/get" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`))
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") || !strings.Contains(err.Error(), "Cookie") {
t.Fatalf("resolve error = %v, want pure 302 refusal with header names", err)
}
}
func TestOpenListResolveRejectsHostedRawURLWithoutCDNRedirect(t *testing.T) {
var probeSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
case "/d/Cloud/Movie.mkv":
probeSeen = true
w.Header().Set("Content-Range", "bytes 0-0/10")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "OpenList-hosted raw_url") || !strings.Contains(err.Error(), "no CDN Location") {
t.Fatalf("resolve error = %v, want hosted raw_url refusal", err)
}
if !probeSeen {
t.Fatal("expected OpenList-hosted raw_url probe")
}
}
func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T) {
var davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/get":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":500,"message":"driver cannot provide raw_url"}`))
case "/dav/Cloud/Movie.mkv":
davSeen = true
w.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
t.Fatalf("resolve error = %v, want raw_url requirement", err)
}
if davSeen {
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
}
}
+94
View File
@@ -0,0 +1,94 @@
// 115 开放平台(openapi)驱动:替代原 cookie 逆向方案。
//
// 账号配置(StrmAccount.Config JSON):
//
// {
// "app_id": "100195125", // 开放平台应用 ID
// "access_token": "...", // 加密存储
// "refresh_token": "...", // 加密存储
// "user_id": "12345", // 可选
// "user_name": "user" // 可选
// }
//
// 授权流程:设备码扫码(官方 PKCE 应用目录)、QMediaSync/MQFamily 中继、
// MoviePilot 轮询、CloudDrive 回跳,见 internal/service/cloud115。
package cloud
import (
"context"
"fmt"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
)
// openAPI115Provider 实现 Provider 接口:List 列目录、Resolve 用 pickcode
// 换下载直链(302 offload,无需代理)、Ping 探测根目录。
type openAPI115Provider struct {
c *cloud115.OpenClient
}
// NewOpenAPI115 构造 115 开放平台驱动。
func NewOpenAPI115(appID, accessToken, refreshToken string) *openAPI115Provider {
return &openAPI115Provider{c: cloud115.NewOpenClient(strings.TrimSpace(appID), accessToken, refreshToken)}
}
func (p *openAPI115Provider) Type() string { return Type115 }
func (p *openAPI115Provider) Ping(ctx context.Context) error {
if strings.TrimSpace(p.c.AppID) == "" {
return fmt.Errorf("115: 缺少开放平台应用 ID,请重新授权")
}
if strings.TrimSpace(p.c.AccessToken) == "" {
return fmt.Errorf("115: 缺少访问令牌,请重新授权")
}
_, _, err := p.c.GetFsList(ctx, "0", 0, 1)
return err
}
func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录
const pageSize = 100
var out []FileEntry
for offset := 0; ; offset += pageSize {
files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize)
if err != nil {
return nil, err
}
for _, f := range files {
out = append(out, FileEntry{
ID: f.FileId,
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
PickCode: f.PickCode,
})
}
if len(files) < pageSize {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
return out, nil
}
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
url, err := p.c.GetDownloadURL(ctx, fileRef)
if err != nil {
return nil, err
}
return &DirectLink{URL: url, Proxy: false}, nil
}
// OpenClient 暴露底层客户端(token 刷新用)。
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
// RefreshToken 刷新访问令牌并返回新令牌;refresh_token 失效时返回
// cloud115.IsRefreshTokenDead(err) 为 true 的错误。
func (p *openAPI115Provider) RefreshToken(refreshToken string) (*cloud115.TokenData, error) {
return p.c.RefreshToken(refreshToken)
}