diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go
index 41e200f..c22fccd 100644
--- a/internal/service/cloud/cloud_test.go
+++ b/internal/service/cloud/cloud_test.go
@@ -1,13 +1,8 @@
package cloud
import (
- "context"
- "encoding/json"
"net/http"
- "net/http/httptest"
- "strings"
"testing"
- "time"
)
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
@@ -21,632 +16,6 @@ func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
}
}
-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(`
-
-
- /dav/
-
-
-
- /dav/115/
- 115
-
-
- /dav/123/Movie.mkv
- Movie.mkv789
-
-`))
- 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(`/dav/`))
- 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 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(`
-
-
- /dav/
-
-
-
- /dav/Cloud/Movie.mkv
- Movie.mkv1024
-
-`))
- }))
- 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 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/%E5%A4%96%E8%AF%AD%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)
- }
-}
-
-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 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")
- }
-}
-
-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(`/dav/`))
- }))
- 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)
- }
-}
-
func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
@@ -658,5 +27,3 @@ func TestUnsupportedProvider(t *testing.T) {
t.Fatal("quark should not be an active cloud provider")
}
}
-
-var _ = time.Second
diff --git a/internal/service/cloud/clouddrive2_test.go b/internal/service/cloud/clouddrive2_test.go
new file mode 100644
index 0000000..9c8d65a
--- /dev/null
+++ b/internal/service/cloud/clouddrive2_test.go
@@ -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(`
+
+
+ /dav/
+
+
+
+ /dav/115/
+ 115
+
+
+ /dav/123/Movie.mkv
+ Movie.mkv789
+
+`))
+ 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(`/dav/`))
+ 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/%E5%A4%96%E8%AF%AD%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)
+ }
+}
diff --git a/internal/service/cloud/openlist_list_test.go b/internal/service/cloud/openlist_list_test.go
new file mode 100644
index 0000000..7dd6164
--- /dev/null
+++ b/internal/service/cloud/openlist_list_test.go
@@ -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(`
+
+
+ /dav/
+
+
+
+ /dav/Cloud/Movie.mkv
+ Movie.mkv1024
+
+`))
+ }))
+ 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(`/dav/`))
+ }))
+ 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)
+ }
+}
diff --git a/internal/service/cloud/openlist_resolve_test.go b/internal/service/cloud/openlist_resolve_test.go
new file mode 100644
index 0000000..2b9b976
--- /dev/null
+++ b/internal/service/cloud/openlist_resolve_test.go
@@ -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")
+ }
+}