feat: improve OpenList cloud storage support

This commit is contained in:
ShukeBta
2026-06-10 00:08:35 +08:00
parent 9f2d7a507b
commit 0f82cb3084
16 changed files with 635 additions and 50 deletions
+20 -2
View File
@@ -80,8 +80,8 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir))
}
mediaType := strings.TrimSpace(in.MediaType)
if mediaType == "" {
mediaType = "movie"
if mediaType == "" || strings.EqualFold(mediaType, "auto") {
mediaType = cloudMountMediaType(strings.TrimSpace(in.Dir), name)
}
libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil {
@@ -124,6 +124,8 @@ func cloudMountLibraryName(typ, dir string) string {
base = "115 网盘"
case cloud.TypeCloudDrive2:
base = "CloudDrive2"
case cloud.TypeOpenList:
base = "OpenList"
}
if dir == "" || dir == "0" {
return base
@@ -131,6 +133,22 @@ func cloudMountLibraryName(typ, dir string) string {
return base + " · " + dir
}
func cloudMountMediaType(dir, name string) string {
text := strings.ToLower(dir + " " + name)
switch {
case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"):
return "adult"
case strings.Contains(text, "动漫") || strings.Contains(text, "动画") || strings.Contains(text, "国漫") || strings.Contains(text, "日番") || strings.Contains(text, "anime"):
return "anime"
case strings.Contains(text, "综艺") || strings.Contains(text, "variety") || strings.Contains(text, "show"):
return "variety"
case strings.Contains(text, "剧") || strings.Contains(text, "series") || strings.Contains(text, "tv"):
return "tv"
default:
return "movie"
}
}
// cloud115QRStartHandler begins a 115 QR-code login and returns the session +
// QR image URL for the frontend to render.
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
+3
View File
@@ -103,8 +103,11 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
"key": "cloud-upload",
"label": "网盘转存",
"items": []gin.H{
{"key": "cloud.auto_sync_enabled", "type": "toggle", "label": "自动同步网盘媒体库"},
{"key": "cloud.sync_interval_seconds", "type": "number", "label": "网盘媒体库同步间隔秒数"},
{"key": "cloud.upload_auto_enabled", "type": "toggle", "label": "启用自动转存"},
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标", "options": []gin.H{
{"value": "openlist", "label": "OpenList(推荐,可桥接 115/123/阿里/夸克)"},
{"value": "clouddrive2", "label": "CloudDrive2(推荐,可桥接 115/123/阿里/夸克)"},
{"value": "alist", "label": "Alist(可桥接多网盘)"},
{"value": "webdav", "label": "WebDAV"},
+6 -5
View File
@@ -6,12 +6,12 @@ package model
type STRMRecord struct {
Base
Title string `gorm:"size:512;not null;index" json:"title"`
URL string `gorm:"size:2048;not null" json:"url"` // STRM 文件指向的 URL
FilePath string `gorm:"size:1024;not null" json:"file_path"` // 本地 STRM 文件路径
Protocol string `gorm:"size:32;not null" json:"protocol"` // webdav / alist / s3 / http / https
URL string `gorm:"size:2048;not null" json:"url"` // STRM 文件指向的 URL
FilePath string `gorm:"size:1024;not null" json:"file_path"` // 本地 STRM 文件路径
Protocol string `gorm:"size:32;not null" json:"protocol"` // webdav / alist / s3 / http / https
FileSize int64 `json:"file_size"`
MediaID string `gorm:"size:36;index" json:"media_id"` // 关联媒体 ID
MediaType string `gorm:"size:16" json:"media_type"` // movie / series
MediaID string `gorm:"size:36;index" json:"media_id"` // 关联媒体 ID
MediaType string `gorm:"size:16" json:"media_type"` // movie / series
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
}
@@ -20,6 +20,7 @@ type STRMRecord struct {
var AllowedSTRMProtocols = []string{
"webdav", "davs",
"alist", "alists",
"openlist", "openlists",
"s3",
"http", "https",
}
+4 -1
View File
@@ -30,6 +30,7 @@ const (
TypeQuark = "quark" // 夸克网盘
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
)
// ErrUnsupported is returned for an unknown provider type.
@@ -84,6 +85,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
return new115(cfg, client), nil
case TypeCloudDrive2:
return newCloudDrive2(cfg, client), nil
case TypeOpenList:
return newOpenList(cfg, client), nil
default:
return nil, ErrUnsupported
}
@@ -91,7 +94,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
// IsCloudType reports whether typ is a cloud-disk provider.
func IsCloudType(typ string) bool {
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
}
// str coerces a config value to a trimmed string.
+98
View File
@@ -372,6 +372,104 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
}
}
func TestOpenListWebDAVListAndResolve(t *testing.T) {
var gotPath, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotDepth = r.Header.Get("Depth")
if r.Method != "PROPFIND" {
t.Fatalf("unexpected method %s", r.Method)
}
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{"server": srv.URL, "username": "u", "password": "p"}, 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)
}
link, err := p.Resolve(context.Background(), entries[0].ID)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != srv.URL+"/dav/Cloud/Movie.mkv" {
t.Fatalf("bad url: %s", link.URL)
}
if !link.Proxy {
t.Fatalf("openlist should default to proxy mode")
}
}
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 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)
+110 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -21,6 +20,8 @@ import (
// 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
@@ -31,10 +32,15 @@ type cloudDrive2Provider struct {
}
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
rawURL := str(cfg["url"])
if rawURL == "" {
rawURL = str(cfg["server"])
}
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, "/"))
ua := str(cfg["ua"])
if ua == "" {
@@ -45,6 +51,8 @@ func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provide
proxy = false
}
return &cloudDrive2Provider{
typ: typ,
name: name,
base: u,
username: str(cfg["username"]),
password: str(cfg["password"]),
@@ -55,7 +63,7 @@ func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provide
}
}
func (p *cloudDrive2Provider) Type() string { return TypeCloudDrive2 }
func (p *cloudDrive2Provider) Type() string { return p.typ }
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
_, err := p.List(ctx, "")
@@ -77,16 +85,16 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, err
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("clouddrive2: list %s returned http %d", target, resp.StatusCode)
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("clouddrive2: decode webdav: %w", err)
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
@@ -119,7 +127,7 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
}
ref := normalizeCloudDAVPath(fileRef)
if ref == "/" {
return nil, errors.New("clouddrive2: file reference required")
return nil, fmt.Errorf("%s: file reference required", p.name)
}
headers := map[string]string{
"User-Agent": p.ua,
@@ -134,7 +142,7 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return errors.New("clouddrive2: missing WebDAV URL")
return fmt.Errorf("%s: missing WebDAV URL", p.name)
}
return nil
}
@@ -150,6 +158,97 @@ func (p *cloudDrive2Provider) auth(req *http.Request) {
}
}
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 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 (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
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
}
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
u := *p.base
basePath := strings.TrimRight(u.EscapedPath(), "/")
+33 -1
View File
@@ -270,6 +270,13 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(ref),
ScrapeStatus: "pending",
}
if ext == ".strm" {
if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" {
m.STRMURL = targetURL
} else if err != nil {
s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err))
}
}
parsedSeason, parsedEpisode := ParseEpisode(name)
m.SeasonNum = parsedSeason
m.EpisodeNum = parsedEpisode
@@ -511,7 +518,32 @@ func cloudEntryRef(typ, id, pickCode string) string {
}
func cloudMediaPath(typ, ref string) string {
return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimSpace(ref)
return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimLeft(strings.TrimSpace(ref), "/")
}
func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) {
if s.storage == nil {
return "", nil
}
content, err := s.storage.CloudReadText(ctx, typ, ref, 64<<10)
if err != nil {
return "", err
}
for _, line := range strings.Split(content, "\n") {
candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
if candidate == "" || strings.HasPrefix(candidate, "#") {
continue
}
u, err := url.Parse(candidate)
if err != nil {
continue
}
switch strings.ToLower(u.Scheme) {
case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists":
return candidate, nil
}
}
return "", nil
}
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
+75
View File
@@ -115,3 +115,78 @@ func TestCloudLibraryPathParsing(t *testing.T) {
t.Fatalf("115 ref = %q, want pick", ref)
}
}
func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "PROPFIND":
if r.URL.Path != "/dav/Links" {
t.Fatalf("unexpected propfind path %s", 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/Links/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/Links/Movie.strm</d:href>
<d:propstat><d:prop><d:displayname>Movie.strm</d:displayname><d:getcontentlength>32</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
case http.MethodGet:
if r.URL.Path != "/dav/Links/Movie.strm" {
t.Fatalf("unexpected get path %s", r.URL.Path)
}
_, _ = w.Write([]byte("https://cdn.example.com/Movie.mkv\n"))
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
log := zap.NewNop()
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
if _, err := storage.Save(t.Context(), StorageInput{
Type: "openlist",
Config: map[string]any{
"url": upstream.URL,
},
}); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "OpenList · Links", Path: "cloud://openlist/Links", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
scanner.SetStorageConfig(storage)
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan cloud: %v", err)
}
if res.Added != 1 {
t.Fatalf("scan result = %#v, want added=1", res)
}
var media model.Media
if err := repos.DB.First(&media).Error; err != nil {
t.Fatal(err)
}
if media.Path != "cloud://openlist/Links/Movie.strm" {
t.Fatalf("path = %q", media.Path)
}
if media.STRMURL != "https://cdn.example.com/Movie.mkv" {
t.Fatalf("strm target = %q", media.STRMURL)
}
}
+91 -2
View File
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -147,6 +148,35 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
return fmt.Errorf("alist returned %d", resp.StatusCode)
}
return nil
case cloud.TypeOpenList:
if hasWebDAVProbeConfig(cfg) {
p, err := cloud.New(in.Type, cfg, s.client)
if err != nil {
return err
}
return p.Ping(ctx)
}
server := strings.TrimRight(strr(cfg["server"]), "/")
if server != "" {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil)
if tok := strr(cfg["token"]); tok != "" {
req.Header.Set("Authorization", tok)
}
resp, err := s.client.Do(req)
if err != nil {
return decorateStorageTransportError("openlist", server, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("openlist returned %d", resp.StatusCode)
}
return nil
}
p, err := cloud.New(in.Type, cfg, s.client)
if err != nil {
return err
}
return p.Ping(ctx)
case "webdav":
u := strr(cfg["url"])
if u == "" {
@@ -159,7 +189,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
req.Header.Set("Depth", "0")
resp, err := s.client.Do(req)
if err != nil {
return err
return decorateStorageTransportError("webdav", u, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
@@ -235,6 +265,42 @@ func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef, c
return p.Resolve(ctx, fileRef)
}
// CloudReadText resolves a small cloud file and returns its text payload. It is
// used for cloud-hosted .strm files: the scanner reads the STRM target once and
// stores the real playback URL, while the media bytes still stay in the cloud.
func (s *StorageConfigService) CloudReadText(ctx context.Context, typ, fileRef string, limit int64) (string, error) {
if limit <= 0 {
limit = 64 << 10
}
link, err := s.CloudResolve(ctx, typ, fileRef, "")
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return "", err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: read strm returned http %d", typ, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return "", err
}
if int64(len(body)) > limit {
return "", fmt.Errorf("%s: strm file is too large", typ)
}
return strings.TrimSpace(strings.TrimPrefix(string(body), "\ufeff")), nil
}
// cloudProviderWithUA builds a provider, overriding the request UA when a
// non-empty clientUA is supplied.
func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, clientUA string) (cloud.Provider, error) {
@@ -270,6 +336,8 @@ func cloudLibraryName(typ string) string {
return "115 网盘"
case cloud.TypeCloudDrive2:
return "CloudDrive2"
case cloud.TypeOpenList:
return "OpenList"
default:
return typ
}
@@ -335,12 +403,33 @@ func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, na
func validStorageType(t string) bool {
switch t {
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList:
return true
}
return false
}
func hasWebDAVProbeConfig(cfg map[string]any) bool {
return strr(cfg["url"]) != "" ||
strr(cfg["webdav_url"]) != "" ||
strr(cfg["username"]) != "" ||
strr(cfg["password"]) != ""
}
func decorateStorageTransportError(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/,管理页面/API 地址通常是 http://host:5244", 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
}
// strr is a tiny helper to avoid importing fmt.Sprint just to coerce
// interface{} → string. (Named "strr" so it doesn't collide with the
// notify channel's `str` helper which already lives in this package.)
+19 -8
View File
@@ -24,7 +24,7 @@ const (
CloudUploadSidecarsKey = "cloud.upload_sidecars"
CloudUploadOverwriteKey = "cloud.upload_overwrite"
CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV / CloudDrive2;115/夸克原生上传需要各自的分片上传私有接口,建议先用 CloudDrive2 或 Alist 桥接后转存。"
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / OpenList / WebDAV / CloudDrive2;115/夸克原生上传需要各自的分片上传私有接口,建议先用 CloudDrive2、OpenList 或 Alist 桥接后转存。"
)
type CloudUploadInput struct {
@@ -129,6 +129,11 @@ func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storag
switch typ {
case "alist":
return newAlistUploader(view.Config), nil
case "openlist":
if strings.TrimSpace(strr(view.Config["server"])) != "" {
return newNamedAlistUploader("openlist", view.Config), nil
}
return newWebDAVUploader(view.Config), nil
case "webdav":
return newWebDAVUploader(view.Config), nil
case "clouddrive2":
@@ -222,13 +227,19 @@ func joinRemotePath(base, rel string) string {
}
type alistUploader struct {
name string
server string
token string
client *http.Client
}
func newAlistUploader(cfg map[string]any) *alistUploader {
return newNamedAlistUploader("alist", cfg)
}
func newNamedAlistUploader(name string, cfg map[string]any) *alistUploader {
return &alistUploader{
name: name,
server: strings.TrimRight(strr(cfg["server"]), "/"),
token: strr(cfg["token"]),
client: &http.Client{},
@@ -237,7 +248,7 @@ func newAlistUploader(cfg map[string]any) *alistUploader {
func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error {
if a.server == "" {
return errors.New("alist missing server")
return fmt.Errorf("%s missing server", a.name)
}
remoteDir = normalizeRemotePath(remoteDir)
if remoteDir == "/" {
@@ -255,7 +266,7 @@ func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error {
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return err
return decorateStorageTransportError(a.name, a.server, err)
}
err = a.checkJSON(resp, "alist mkdir")
if err != nil && !isAlreadyExistsMessage(err.Error()) {
@@ -275,7 +286,7 @@ func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, er
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return false, err
return false, decorateStorageTransportError(a.name, a.server, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
@@ -305,7 +316,7 @@ func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string
req.Header.Set("File-Path", url.PathEscape(normalizeRemotePath(remotePath)))
resp, err := a.client.Do(req)
if err != nil {
return err
return decorateStorageTransportError(a.name, a.server, err)
}
return a.checkJSON(resp, "alist upload")
}
@@ -373,7 +384,7 @@ func (w *webDAVUploader) ensureDir(ctx context.Context, remoteDir string) error
w.auth(req)
resp, err := w.client.Do(req)
if err != nil {
return err
return decorateStorageTransportError("webdav", w.urlFor(current), err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
@@ -396,7 +407,7 @@ func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, e
w.auth(req)
resp, err := w.client.Do(req)
if err != nil {
return false, err
return false, decorateStorageTransportError("webdav", w.urlFor(remotePath), err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
@@ -420,7 +431,7 @@ func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath strin
req.ContentLength = size
resp, err := w.client.Do(req)
if err != nil {
return err
return decorateStorageTransportError("webdav", w.urlFor(remotePath), err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
+75
View File
@@ -93,6 +93,81 @@ func TestStorageConfigUploadLocalToAlist(t *testing.T) {
}
}
func TestStorageConfigUploadLocalToOpenListAPI(t *testing.T) {
var uploaded []string
openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/fs/mkdir":
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/get":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"code":404,"message":"not found"}`))
case "/api/fs/put":
if r.Header.Get("Authorization") != "openlist-token" {
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
}
decoded, err := url.PathUnescape(r.Header.Get("File-Path"))
if err != nil {
t.Fatalf("decode file path: %v", err)
}
uploaded = append(uploaded, decoded)
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
default:
t.Fatalf("unexpected openlist path %s", r.URL.Path)
}
}))
defer openlist.Close()
_, storage := newStorageUploadTestService(t)
if _, err := storage.Save(t.Context(), StorageInput{
Type: "openlist",
Config: map[string]any{
"server": openlist.URL,
"token": "openlist-token",
},
}); err != nil {
t.Fatal(err)
}
source := t.TempDir()
if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
Type: "openlist",
SourcePath: source,
DestPath: "/OpenList",
Recursive: true,
})
if err != nil {
t.Fatalf("upload local: %v", err)
}
if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" {
t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
}
}
func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) {
openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200}`))
}))
defer openlist.Close()
_, storage := newStorageUploadTestService(t)
badHTTPS := "https://" + strings.TrimPrefix(openlist.URL, "http://")
err := storage.Test(t.Context(), StorageInput{
Type: "openlist",
Config: map[string]any{
"server": badHTTPS,
},
})
if err == nil {
t.Fatal("want protocol mismatch error")
}
if !strings.Contains(err.Error(), "请改用 http://") || !strings.Contains(err.Error(), "server gave HTTP response to HTTPS client") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
var uploaded []string
alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {