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
+12 -4
View File
@@ -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 桥接,避免在项目内维护各网盘私有分片上传协议。
---
+12 -4
View File
@@ -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.
---
+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) {
+1 -1
View File
@@ -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
+16 -1
View File
@@ -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' },
+60 -10
View File
@@ -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<string, string> = {
alist: 'ALIST',
openlist: 'OpenList',
webdav: 'WEBDAV',
s3: 'S3',
cloud115: '115网盘',
@@ -25,7 +26,7 @@ const TYPE_LABEL: Record<string, string> = {
// 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<StorageType>('alist')
const [active, setActive] = useState<StorageType>('openlist')
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@@ -35,13 +36,13 @@ export function StorageConfigPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
<p className="text-sm text-ink-50">
配置 Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放
配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放
</p>
</div>
</div>
<div className="flex gap-2 border-b border-gray-200">
{(['alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
{(['openlist', 'alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
<button
key={t}
onClick={() => setActive(t)}
@@ -67,6 +68,14 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
{ key: 'token', label: 'Token', secret: true },
],
openlist: [
{ key: 'server', label: 'OpenList Server URL(API / 转存)', placeholder: 'http://NAS-IP:5244' },
{ key: 'token', label: 'OpenList Token(API 转存可选)', secret: true },
{ key: 'url', label: 'WebDAV URL(浏览/挂载)', placeholder: 'http://NAS-IP:5244/dav/' },
{ key: 'username', label: 'WebDAV 用户名' },
{ key: 'password', label: 'WebDAV 密码', secret: true },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
webdav: [
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
{ key: 'username', label: '用户名' },
@@ -234,11 +243,11 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
const [overwrite, setOverwrite] = useState(false)
const [busy, setBusy] = useState(false)
const supported = type === 'alist' || type === 'webdav' || type === 'clouddrive2'
const supported = type === 'alist' || type === 'openlist' || type === 'webdav' || type === 'clouddrive2'
const submit = async () => {
if (!supported) {
toast.error('本地直传目前支持 Alist / WebDAV / CloudDrive2。115/123/夸克建议通过 CloudDrive2 或 Alist 桥接后转存。')
toast.error('本地直传目前支持 OpenList / Alist / WebDAV / CloudDrive2。115/123/夸克建议通过 OpenList、CloudDrive2 或 Alist 桥接后转存。')
return
}
if (!sourcePath.trim()) {
@@ -282,7 +291,12 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
</div>
{!supported && (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 CloudDrive2 或 Alist 后,在这里选择 CloudDrive2 / Alist 转存。
115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。
</p>
)}
{type === 'openlist' && (
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
OpenList 默认端口常见为 5244,WebDAV 地址通常是 http://host:5244/dav/。如果未配置 HTTPS 反代,请不要填写 https://,否则会出现 “server gave HTTP response to HTTPS client”。
</p>
)}
{type === 'clouddrive2' && (
@@ -421,7 +435,8 @@ function CloudBrowser({ type }: { type: StorageType }) {
const [items, setItems] = useState<CloudEntry[]>([])
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 (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
@@ -493,6 +534,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
value={mountMediaType}
onChange={(event) => setMountMediaType(event.target.value)}
>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
@@ -502,11 +544,19 @@ function CloudBrowser({ type }: { type: StorageType }) {
<button
type="button"
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10"
disabled={mounting || loading}
disabled={mounting || batchMounting || loading}
onClick={mountCurrent}
>
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'}
</button>
<button
type="button"
className="rounded border border-blue-300 px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50"
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
onClick={mountVisibleDirectories}
>
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下文件夹'}
</button>
</div>
</div>
{loading ? (
@@ -514,7 +564,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
<Loader2 className="animate-spin" size={16} />
</div>
) : error ? (
<p className="py-2 text-sm text-red-400">{error}(请先填写有效 Cookie 并保存)</p>
<p className="py-2 text-sm text-red-400">{error}</p>
) : items.length === 0 ? (
<p className="py-2 text-sm text-ink-50">该目录为空</p>
) : (