From 0f82cb3084cb9986ec9f4ab79f962f86de85d81f Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Wed, 10 Jun 2026 00:08:35 +0800 Subject: [PATCH] feat: improve OpenList cloud storage support --- README.md | 16 +++- README_EN.md | 16 +++- internal/handler/cloud.go | 22 ++++- internal/handler/system_extra.go | 3 + internal/model/strm.go | 11 ++- internal/service/cloud/cloud.go | 5 +- internal/service/cloud/cloud_test.go | 98 +++++++++++++++++++ internal/service/cloud/clouddrive2.go | 121 +++++++++++++++++++++--- internal/service/scanner.go | 34 ++++++- internal/service/scanner_cloud_test.go | 75 +++++++++++++++ internal/service/storage_config.go | 93 +++++++++++++++++- internal/service/storage_upload.go | 27 ++++-- internal/service/storage_upload_test.go | 75 +++++++++++++++ web/src/api/storage_config.ts | 2 +- web/src/pages/SettingsPage.tsx | 17 +++- web/src/pages/StorageConfigPage.tsx | 70 ++++++++++++-- 16 files changed, 635 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 5ae929e..860b222 100644 --- a/README.md +++ b/README.md @@ -380,18 +380,26 @@ http://host.docker.internal:8085 启用智能分类后,下载会自动进入 `/downloads/动画电影`、`/downloads/国产剧`、`/downloads/综艺` 等分类目录。 -### 网盘与 CloudDrive2 +### 网盘、OpenList 与 CloudDrive2 -MediaStationGo 内置 Alist、WebDAV、115、夸克和 CloudDrive2 外部存储配置。推荐新手优先使用 CloudDrive2 或 Alist 作为桥接层:CloudDrive2 已经对接 115、123、阿里、夸克等多种网盘,项目通过它的 WebDAV 入口即可完成浏览、挂载媒体库、本地媒体转存和反代播放。 +MediaStationGo 内置 OpenList、Alist、WebDAV、115、夸克和 CloudDrive2 外部存储配置。推荐新手优先使用 OpenList、CloudDrive2 或 Alist 作为桥接层:它们可以对接 115、123、阿里、夸克等多种网盘,项目通过 OpenList/Alist API 或 WebDAV 入口即可完成浏览、挂载媒体库、本地媒体转存和反代播放。 -使用方式: +OpenList 推荐用法: + +1. 在 OpenList 中挂载你的 115 / 123 / 阿里 / 夸克等网盘。 +2. 在 MediaStationGo 的「外部存储」中选择 `OpenList`。 +3. `OpenList Server URL` 填管理/API 地址,例如 `http://NAS-IP:5244`。 +4. `WebDAV URL` 填 WebDAV 地址,例如 `http://NAS-IP:5244/dav/`。 +5. 如果 OpenList 没有配置 HTTPS 反向代理,不要填写 `https://`。`Propfind "https://...": http: server gave HTTP response to HTTPS client` 表示你把 HTTP 服务误填成了 HTTPS。 + +CloudDrive2 推荐用法: 1. 在 CloudDrive2 中挂载你的 115 / 123 / 阿里 / 夸克等网盘。 2. 在 MediaStationGo 的「外部存储」中选择 `CloudDrive2`。 3. 填写 CloudDrive2 WebDAV 地址,例如 `http://host.docker.internal:19798/dav` 或 `http://NAS-IP:19798/dav`。 4. 保存后可直接浏览网盘目录,或挂载为媒体库进行扫描播放。 -115 原生接口保留 Cookie / 扫码登录、目录浏览和 302 播放能力;本地文件上传建议优先走 CloudDrive2 / Alist 桥接,避免在项目内维护各网盘私有分片上传协议。 +115 原生接口保留 Cookie / 扫码登录、目录浏览和 302 播放能力;本地文件上传建议优先走 OpenList / CloudDrive2 / Alist 桥接,避免在项目内维护各网盘私有分片上传协议。 --- diff --git a/README_EN.md b/README_EN.md index baf2fc5..9e6e0d4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -362,18 +362,26 @@ If qBittorrent also runs in Docker, mount the same host download directory as `/ With smart classification enabled, downloads are saved to folders such as `/downloads/动画电影`, `/downloads/国产剧`, and `/downloads/综艺`. -### Cloud Drives and CloudDrive2 +### Cloud Drives, OpenList, and CloudDrive2 -MediaStationGo supports Alist, WebDAV, 115, Quark, and CloudDrive2 as external storage backends. For most users, CloudDrive2 or Alist is the recommended bridge layer: CloudDrive2 already integrates many cloud drives such as 115, 123Pan, Aliyun Drive, and Quark, while MediaStationGo can use its WebDAV endpoint for browsing, library mounting, local-media upload, and authenticated proxy playback. +MediaStationGo supports OpenList, Alist, WebDAV, 115, Quark, and CloudDrive2 as external storage backends. For most users, OpenList, CloudDrive2, or Alist is the recommended bridge layer: they can integrate many cloud drives such as 115, 123Pan, Aliyun Drive, and Quark, while MediaStationGo can use OpenList/Alist APIs or WebDAV endpoints for browsing, library mounting, local-media upload, and authenticated proxy playback. -Typical setup: +Typical OpenList setup: + +1. Mount your 115 / 123Pan / Aliyun / Quark drive inside OpenList. +2. Select `OpenList` in MediaStationGo → External Storage. +3. Set `OpenList Server URL` to the management/API endpoint, for example `http://NAS-IP:5244`. +4. Set `WebDAV URL` to the WebDAV endpoint, for example `http://NAS-IP:5244/dav/`. +5. Do not use `https://` unless OpenList is actually behind an HTTPS reverse proxy. `Propfind "https://...": http: server gave HTTP response to HTTPS client` means an HTTP service was entered as HTTPS. + +Typical CloudDrive2 setup: 1. Mount your 115 / 123Pan / Aliyun / Quark drive inside CloudDrive2. 2. Select `CloudDrive2` in MediaStationGo → External Storage. 3. Fill the CloudDrive2 WebDAV URL, for example `http://host.docker.internal:19798/dav` or `http://NAS-IP:19798/dav`. 4. Save the config, then browse the cloud directory or mount it as a media library. -The native 115 adapter still supports cookie / QR login, directory browsing, and 302 playback. For local file upload, prefer CloudDrive2 / Alist bridging instead of maintaining every provider's private chunk-upload protocol inside this project. +The native 115 adapter still supports cookie / QR login, directory browsing, and 302 playback. For local file upload, prefer OpenList / CloudDrive2 / Alist bridging instead of maintaining every provider's private chunk-upload protocol inside this project. --- diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index a753220..5467c7b 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -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 { diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 50034fc..7fa1b17 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -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"}, diff --git a/internal/model/strm.go b/internal/model/strm.go index c32dbaa..ffedeed 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -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", } diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index b4693d7..12d0d83 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -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. diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index 6f8e384..723a5ba 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -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(` + + + /dav/ + + + + /dav/Cloud/Movie.mkv + Movie.mkv1024 + +`)) + })) + 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(`/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 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) diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index 125cee8..078165a 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -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(), "/") diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 3ea430c..9defd31 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -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) { diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index 704ebd2..8e1087e 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -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(` + + + /dav/Links/ + + + + /dav/Links/Movie.strm + Movie.strm32 + +`)) + 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) + } +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index 9392a6c..b43e154 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -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.) diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go index 1e2c2af..ca82ec2 100644 --- a/internal/service/storage_upload.go +++ b/internal/service/storage_upload.go @@ -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() diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go index bb6ebc1..b632fb7 100644 --- a/internal/service/storage_upload_test.go +++ b/internal/service/storage_upload_test.go @@ -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) { diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 04410ad..7e61ffe 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -1,6 +1,6 @@ import { api } from './client' -export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2' +export type StorageType = 'alist' | 'openlist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2' export interface CloudEntry { id: string diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index a7e2ed4..1beaf9c 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -291,8 +291,22 @@ const GROUPS: SettingGroup[] = [ { key: 'cloud-upload', label: '网盘转存', - description: '把本地媒体复制上传到外部存储。推荐:将 115、123、夸克等挂载到 CloudDrive2 或 Alist 后,使用桥接目标转存。', + description: '把本地媒体复制上传到外部存储。推荐:将 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,使用桥接目标转存。', items: [ + { + key: 'cloud.auto_sync_enabled', + label: '自动同步网盘媒体库', + type: 'toggle', + hint: '开启后后台会按间隔刷新已挂载的 cloud:// 媒体库,自动生成或更新 302/STRM 播放入口;不会下载网盘文件到本地。', + defaultValue: 'true', + }, + { + key: 'cloud.sync_interval_seconds', + label: '网盘媒体库同步间隔秒数', + type: 'number', + hint: '最小 300 秒,建议 1800 秒或更高;手动可在任务调度中运行 cloud_sync。', + defaultValue: '1800', + }, { key: 'cloud.upload_auto_enabled', label: '启用自动转存', @@ -306,6 +320,7 @@ const GROUPS: SettingGroup[] = [ type: 'select', defaultValue: 'alist', options: [ + { value: 'openlist', label: 'OpenList(推荐,可桥接 115/123/阿里/夸克)' }, { value: 'clouddrive2', label: 'CloudDrive2(推荐,可桥接 115/123/阿里/夸克)' }, { value: 'alist', label: 'Alist(可桥接多网盘)' }, { value: 'webdav', label: 'WebDAV' }, diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index 334aa89..bdfff8d 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -10,10 +10,11 @@ import { type StorageType, } from '../api/storage_config' -const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2'] +const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist'] const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t) const TYPE_LABEL: Record = { alist: 'ALIST', + openlist: 'OpenList', webdav: 'WEBDAV', s3: 'S3', cloud115: '115网盘', @@ -25,7 +26,7 @@ const TYPE_LABEL: Record = { // the import / playback / STRM subsystems. Mirrors the Vue UI's // `admin/storage/*` tabs in a tabbed React surface. export function StorageConfigPage() { - const [active, setActive] = useState('alist') + const [active, setActive] = useState('openlist') return (
@@ -35,13 +36,13 @@ export function StorageConfigPage() {

外部存储

- 配置 Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放 + 配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放

- {(['alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => ( + {(['openlist', 'alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
{!supported && (

- 115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 CloudDrive2 或 Alist 后,在这里选择 CloudDrive2 / Alist 转存。 + 115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。 +

+ )} + {type === 'openlist' && ( +

+ OpenList 默认端口常见为 5244,WebDAV 地址通常是 http://host:5244/dav/。如果未配置 HTTPS 反代,请不要填写 https://,否则会出现 “server gave HTTP response to HTTPS client”。

)} {type === 'clouddrive2' && ( @@ -421,7 +435,8 @@ function CloudBrowser({ type }: { type: StorageType }) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [mounting, setMounting] = useState(false) - const [mountMediaType, setMountMediaType] = useState('movie') + const [batchMounting, setBatchMounting] = useState(false) + const [mountMediaType, setMountMediaType] = useState('auto') const [error, setError] = useState('') const cur = stack[stack.length - 1] @@ -473,6 +488,32 @@ function CloudBrowser({ type }: { type: StorageType }) { } } + const mountVisibleDirectories = async () => { + const dirs = items.filter((item) => item.is_dir) + if (dirs.length === 0) { + toast.error('当前目录下没有可挂载的子目录') + return + } + setBatchMounting(true) + let ok = 0 + let failed = 0 + const label = TYPE_LABEL[type] ?? type + for (const dir of dirs) { + try { + await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto') + ok += 1 + } catch { + failed += 1 + } + } + if (failed > 0) { + toast.error(`已挂载 ${ok} 个目录,失败 ${failed} 个;请查看日志或逐个挂载定位`) + } else { + toast.success(`已挂载 ${ok} 个目录为媒体库,后台会自动生成 302/STRM 播放入口`) + } + setBatchMounting(false) + } + return (
e.preventDefault()}>
@@ -493,6 +534,7 @@ function CloudBrowser({ type }: { type: StorageType }) { value={mountMediaType} onChange={(event) => setMountMediaType(event.target.value)} > + @@ -502,11 +544,19 @@ function CloudBrowser({ type }: { type: StorageType }) { +
{loading ? ( @@ -514,7 +564,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
) : error ? ( -

{error}(请先填写有效 Cookie 并保存)

+

{error}

) : items.length === 0 ? (

该目录为空

) : (