mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
feat: improve OpenList cloud storage support
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(), "/")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user