feat: add clouddrive2 storage bridge

This commit is contained in:
ShukeBta
2026-06-09 20:22:00 +08:00
parent eda5d9a978
commit eab8c3c709
14 changed files with 616 additions and 71 deletions
+13
View File
@@ -380,6 +380,19 @@ http://host.docker.internal:8085
启用智能分类后,下载会自动进入 `/downloads/动画电影`、`/downloads/国产剧`、`/downloads/综艺` 等分类目录。
### 网盘与 CloudDrive2
MediaStationGo 内置 Alist、WebDAV、115、夸克和 CloudDrive2 外部存储配置。推荐新手优先使用 CloudDrive2 或 Alist 作为桥接层:CloudDrive2 已经对接 115、123、阿里、夸克等多种网盘,项目通过它的 WebDAV 入口即可完成浏览、挂载媒体库、本地媒体转存和反代播放。
使用方式:
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 桥接,避免在项目内维护各网盘私有分片上传协议。
---
## 🐳 Docker Compose 配置示例
+13
View File
@@ -362,6 +362,19 @@ 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
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.
Typical 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.
---
## 🐳 Docker Compose Configuration
+10
View File
@@ -122,6 +122,8 @@ func cloudMountLibraryName(typ, dir string) string {
base = "夸克网盘"
case cloud.Type115:
base = "115 网盘"
case cloud.TypeCloudDrive2:
base = "CloudDrive2"
}
if dir == "" || dir == "0" {
return base
@@ -133,6 +135,10 @@ func cloudMountLibraryName(typ, dir string) string {
// QR image URL for the frontend to render.
func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Param("type") != cloud.Type115 {
c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"})
return
}
sess, err := cloud.QRStart(c.Request.Context(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -146,6 +152,10 @@ func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc {
// session cookie so the frontend can save it as the storage credential.
func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Param("type") != cloud.Type115 {
c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"})
return
}
var sess cloud.QRSession
if err := c.ShouldBindJSON(&sess); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+7 -1
View File
@@ -104,7 +104,13 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
"label": "网盘转存",
"items": []gin.H{
{"key": "cloud.upload_auto_enabled", "type": "toggle", "label": "启用自动转存"},
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标"},
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标", "options": []gin.H{
{"value": "clouddrive2", "label": "CloudDrive2(推荐,可桥接 115/123/阿里/夸克)"},
{"value": "alist", "label": "Alist(可桥接多网盘)"},
{"value": "webdav", "label": "WebDAV"},
{"value": "cloud115", "label": "115 原生(待接分片上传)"},
{"value": "quark", "label": "夸克原生(待接分片上传)"},
}},
{"key": "cloud.upload_source_dir", "type": "text", "label": "本地源目录"},
{"key": "cloud.upload_dest_path", "type": "text", "label": "网盘目标目录"},
{"key": "cloud.upload_recursive", "type": "toggle", "label": "递归扫描源目录"},
+6 -3
View File
@@ -27,8 +27,9 @@ var timeNow = time.Now
// Provider types recognised by the registry.
const (
TypeQuark = "quark" // 夸克网盘
Type115 = "cloud115" // 115 网盘
TypeQuark = "quark" // 夸克网盘
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
)
// ErrUnsupported is returned for an unknown provider type.
@@ -81,6 +82,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
return newQuark(cfg, client), nil
case Type115:
return new115(cfg, client), nil
case TypeCloudDrive2:
return newCloudDrive2(cfg, client), nil
default:
return nil, ErrUnsupported
}
@@ -88,7 +91,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
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2
}
// str coerces a config value to a trimmed string.
+69
View File
@@ -303,6 +303,75 @@ func Test115QRFlow(t *testing.T) {
}
}
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
var gotAuth, gotDepth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotDepth = r.Header.Get("Depth")
if r.Method != "PROPFIND" {
t.Fatalf("unexpected method %s", r.Method)
}
if r.URL.Path != "/dav" {
t.Fatalf("unexpected 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/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/115/</d:href>
<d:propstat><d:prop><d:displayname>115</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/dav/123/Movie.mkv</d:href>
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>789</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
</d:response>
</d:multistatus>`))
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if gotDepth != "1" {
t.Fatalf("Depth = %q, want 1", gotDepth)
}
if !strings.HasPrefix(gotAuth, "Basic ") {
t.Fatalf("missing basic auth: %q", gotAuth)
}
if len(entries) != 2 {
t.Fatalf("entries = %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "/115" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), entries[1].ID)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != srv.URL+"/dav/123/Movie.mkv" {
t.Fatalf("bad url: %s", link.URL)
}
if !link.Proxy {
t.Fatalf("clouddrive2 should default to proxy mode")
}
if !strings.HasPrefix(link.Headers["Authorization"], "Basic ") {
t.Fatalf("resolve must carry basic auth: %#v", link.Headers)
}
}
func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
+254
View File
@@ -0,0 +1,254 @@
package cloud
import (
"context"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
//
// CloudDrive2 already integrates many cloud disks (115 / 123 / Aliyun / Quark
// and more). Treating it as a WebDAV-backed cloud provider lets MediaStationGo
// browse, mount and upload to those disks without carrying every provider's
// private chunk-upload protocol in this project.
type cloudDrive2Provider struct {
base *url.URL
username string
password string
token string
ua string
client *http.Client
proxy bool
}
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
rawURL := str(cfg["url"])
if rawURL == "" {
rawURL = str(cfg["server"])
}
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
proxy := true
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
proxy = false
}
return &cloudDrive2Provider{
base: u,
username: str(cfg["username"]),
password: str(cfg["password"]),
token: str(cfg["token"]),
ua: ua,
client: client,
proxy: proxy,
}
}
func (p *cloudDrive2Provider) Type() string { return TypeCloudDrive2 }
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
_, err := p.List(ctx, "")
return err
}
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
if err := p.validate(); err != nil {
return nil, err
}
target := normalizeCloudDAVPath(dir)
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
if err != nil {
return nil, err
}
p.auth(req)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, 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)
}
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)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
out := make([]FileEntry, 0, len(multi.Responses))
for _, item := range multi.Responses {
entryPath, err := p.entryIDFromHref(item.Href, basePath)
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
continue
}
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: entryPath,
Name: name,
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
})
}
return out, nil
}
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if err := p.validate(); err != nil {
return nil, err
}
ref := normalizeCloudDAVPath(fileRef)
if ref == "/" {
return nil, errors.New("clouddrive2: file reference required")
}
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
}
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return errors.New("clouddrive2: missing WebDAV URL")
}
return nil
}
func (p *cloudDrive2Provider) auth(req *http.Request) {
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
return
}
if p.username != "" {
req.SetBasicAuth(p.username, p.password)
}
}
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
u := *p.base
basePath := strings.TrimRight(u.EscapedPath(), "/")
segments := make([]string, 0)
if basePath != "" && basePath != "/" {
segments = append(segments, strings.Trim(basePath, "/"))
}
for _, part := range strings.Split(strings.Trim(normalizeCloudDAVPath(remotePath), "/"), "/") {
if part != "" {
segments = append(segments, url.PathEscape(part))
}
}
u.RawPath = ""
u.Path = "/" + strings.Join(segments, "/")
return u.String()
}
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
if href == "" {
return "", nil
}
parsed, err := url.Parse(href)
if err != nil {
return "", err
}
hrefPath := parsed.EscapedPath()
if hrefPath == "" {
hrefPath = href
}
if basePath != "" && basePath != "/" {
hrefPath = strings.TrimPrefix(hrefPath, basePath)
}
if decoded, err := url.PathUnescape(hrefPath); err == nil {
hrefPath = decoded
}
return normalizeCloudDAVPath(hrefPath), nil
}
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
type cloudDAVMultiStatus struct {
Responses []cloudDAVResponse `xml:"response"`
}
type cloudDAVResponse struct {
Href string `xml:"href"`
PropStat cloudDAVPropStat `xml:"propstat"`
}
type cloudDAVPropStat struct {
Prop cloudDAVProp `xml:"prop"`
}
type cloudDAVProp struct {
DisplayName string `xml:"displayname"`
ContentLength string `xml:"getcontentlength"`
ResourceType cloudDAVResourceType `xml:"resourcetype"`
}
type cloudDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
if cleaned == "." {
return "/"
}
return cleaned
}
func sameCloudDAVPath(a, b string) bool {
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
}
func parseDAVSize(raw string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return n
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+5 -3
View File
@@ -86,7 +86,7 @@ func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error)
var cfg map[string]any
_ = json.Unmarshal([]byte(plain), &cfg)
// Redact secrets when listing.
for _, k := range []string{"password", "secret_key", "token"} {
for _, k := range []string{"password", "secret_key", "token", "cookie", "access_key"} {
if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" {
cfg[k] = "********"
}
@@ -184,7 +184,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
}
defer resp.Body.Close()
return nil
case cloud.TypeQuark, cloud.Type115:
case cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
p, err := cloud.New(in.Type, cfg, s.client)
if err != nil {
return err
@@ -268,6 +268,8 @@ func cloudLibraryName(typ string) string {
return "夸克网盘"
case cloud.Type115:
return "115 网盘"
case cloud.TypeCloudDrive2:
return "CloudDrive2"
default:
return typ
}
@@ -333,7 +335,7 @@ 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:
case "alist", "s3", "webdav", cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
return true
}
return false
+3 -1
View File
@@ -24,7 +24,7 @@ const (
CloudUploadSidecarsKey = "cloud.upload_sidecars"
CloudUploadOverwriteKey = "cloud.upload_overwrite"
CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV;115/夸克原生上传需要各自的分片上传私有接口,建议先用 Alist 挂载 115/夸克后选择 Alist 转存。"
CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV / CloudDrive2;115/夸克原生上传需要各自的分片上传私有接口,建议先用 CloudDrive2 或 Alist 桥接后转存。"
)
type CloudUploadInput struct {
@@ -131,6 +131,8 @@ func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storag
return newAlistUploader(view.Config), nil
case "webdav":
return newWebDAVUploader(view.Config), nil
case "clouddrive2":
return newWebDAVUploader(view.Config), nil
case "s3":
return nil, errors.New("s3 local upload is not implemented yet")
case "cloud115", "quark":
+46
View File
@@ -146,6 +146,52 @@ func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
}
}
func TestStorageConfigUploadLocalToCloudDrive2(t *testing.T) {
var uploaded []string
dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "MKCOL":
w.WriteHeader(http.StatusCreated)
case http.MethodHead:
w.WriteHeader(http.StatusNotFound)
case http.MethodPut:
uploaded = append(uploaded, r.URL.Path)
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %s %s", r.Method, r.URL.Path)
}
}))
defer dav.Close()
_, storage := newStorageUploadTestService(t)
if _, err := storage.Save(t.Context(), StorageInput{
Type: "clouddrive2",
Config: map[string]any{
"url": dav.URL + "/dav",
"username": "user",
"password": "pass",
},
}); err != nil {
t.Fatal(err)
}
source := t.TempDir()
if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
Type: "clouddrive2",
SourcePath: source,
DestPath: "/MediaStationGo",
Recursive: true,
})
if err != nil {
t.Fatalf("upload local: %v", err)
}
if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/dav/MediaStationGo/Movie.mkv" {
t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
}
}
func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+1 -1
View File
@@ -1,6 +1,6 @@
import { api } from './client'
export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark'
export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2'
export interface CloudEntry {
id: string
+167 -54
View File
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast'
import {
Activity, Bell, Clock, CloudDownload, Compass, Film,
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
ListMusic, LogOut, Rss, Search,
ListMusic, LogOut, Rss, Search, Trash2,
Settings, Sliders, Sparkles, UserCog,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
} from 'lucide-react'
@@ -32,6 +32,7 @@ export function Layout() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
const [profiles, setProfiles] = useState<PlayProfile[]>([])
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -82,6 +83,26 @@ export function Layout() {
const isAdmin = user?.role === 'admin'
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? null
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
const isRouteIn = (paths: string[]) =>
paths.some((path) => (path === '/' ? location.pathname === '/' : location.pathname.startsWith(path)))
const toggleGroup = (key: string) =>
setOpenGroups((current) => ({ ...current, [key]: !current[key] }))
useEffect(() => {
const groupPaths: Record<string, string[]> = {
media: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
personal: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
downloads: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
tools: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'],
system: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
}
const active = Object.entries(groupPaths).find(([, paths]) => isRouteIn(paths))?.[0]
if (active) {
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -150,56 +171,97 @@ export function Layout() {
</div>
{/* Navigation List */}
<nav className="flex-1 overflow-y-auto px-4 py-6 space-y-6 scrollbar-hide">
{/* Navigation Group: Main */}
<div>
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/libraries" icon={<LibraryIcon size={18} />} label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<Image size={18} />} label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
{can('can_view_discover') && <SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_use_ai') && <SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_cast') && <SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_use_ai_assistant') && <SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
<SidebarGroup
id="media"
icon={<Home size={18} />}
label="影音中心"
collapsed={!sidebarExpanded}
open={openGroups.media ?? true}
active={isRouteIn(['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'])}
onToggle={toggleGroup}
>
<SidebarLink to="/" icon={<Home size={16} />} label="系统首页" end child />
<SidebarLink to="/libraries" icon={<LibraryIcon size={16} />} label="媒体库" child />
<SidebarLink to="/poster-wall" icon={<Image size={16} />} label="海报墙" child />
{can('can_view_discover') && <SidebarLink to="/discover" icon={<Compass size={16} />} label="精彩发现" child />}
{can('can_use_ai') && <SidebarLink to="/search" icon={<Search size={16} />} label="智能搜索" child />}
{can('can_cast') && <SidebarLink to="/dlna" icon={<Cast size={16} />} label="DLNA 投屏" child />}
{can('can_use_ai_assistant') && <SidebarLink to="/ai" icon={<Sparkles size={16} />} label="AI 助理" child />}
</SidebarGroup>
{/* Navigation Group: Personal */}
<div>
<SectionHeader label="个人空间" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
<SidebarGroup
id="personal"
icon={<UserIcon size={18} />}
label="个人空间"
collapsed={!sidebarExpanded}
open={openGroups.personal ?? false}
active={isRouteIn(['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'])}
onToggle={toggleGroup}
>
<SidebarLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" child />
<SidebarLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" child />
<SidebarLink to="/history" icon={<Clock size={16} />} label="观看历史" child />
<SidebarLink to="/profile" icon={<UserIcon size={16} />} label="账号信息" child />
<SidebarLink to="/play-profiles" icon={<UserCog size={16} />} label="观影 Profile" child />
</SidebarGroup>
{/* Navigation Group: Automation */}
{(can('can_manage_downloads') || can('can_manage_subscriptions') || can('can_manage_sites')) && <div>
<SectionHeader label="下载订阅" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
{can('can_manage_downloads') && <SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{isAdmin && <SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_manage_subscriptions') && <SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_manage_sites') && <SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
</div>
</div>}
{(can('can_manage_downloads') || can('can_manage_subscriptions') || can('can_manage_sites')) && (
<SidebarGroup
id="downloads"
icon={<CloudDownload size={18} />}
label="下载订阅"
collapsed={!sidebarExpanded}
open={openGroups.downloads ?? false}
active={isRouteIn(['/downloads', '/download-clients', '/subscriptions', '/site-search'])}
onToggle={toggleGroup}
>
{can('can_manage_downloads') && <SidebarLink to="/downloads" icon={<CloudDownload size={16} />} label="下载中心" child />}
{can('can_manage_subscriptions') && <SidebarLink to="/subscriptions" icon={<Rss size={16} />} label="订阅管理" child />}
{can('can_manage_sites') && <SidebarLink to="/site-search" icon={<Search size={16} />} label="站点检索" child />}
{isAdmin && <SidebarLink to="/download-clients" icon={<Sliders size={16} />} label="下载器管理" child />}
</SidebarGroup>
)}
{/* Navigation Group: Admin Dashboard */}
{isAdmin && (
<div>
<SectionHeader label="统一管理" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="授权许可" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
<>
<SidebarGroup
id="tools"
icon={<HardDrive size={18} />}
label="存储工具"
collapsed={!sidebarExpanded}
open={openGroups.tools ?? false}
active={isRouteIn(['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'])}
onToggle={toggleGroup}
>
<SidebarLink to="/storage" icon={<HardDrive size={16} />} label="存储与文件" child />
<SidebarLink to="/storage-config" icon={<CloudDownload size={16} />} label="外部存储" child />
<SidebarLink to="/files" icon={<LibraryIcon size={16} />} label="文件管理" child />
<SidebarLink to="/strm" icon={<Cast size={16} />} label="STRM 管理" child />
<SidebarLink to="/duplicates" icon={<Image size={16} />} label="重复文件" child />
<SidebarLink to="/tasks" icon={<Activity size={16} />} label="任务队列" child />
<SidebarLink to="/scheduler" icon={<Clock size={16} />} label="计划任务" child />
<SidebarLink to="/recycle" icon={<Trash2 size={16} />} label="回收站" child />
<SidebarLink to="/stats" icon={<Activity size={16} />} label="运行状态" child />
</SidebarGroup>
<SidebarGroup
id="system"
icon={<Settings size={18} />}
label="系统管理"
collapsed={!sidebarExpanded}
open={openGroups.system ?? false}
active={isRouteIn(['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'])}
onToggle={toggleGroup}
>
<SidebarLink to="/admin" icon={<Settings size={16} />} label="媒体与用户" child />
<SidebarLink to="/sites" icon={<Globe size={16} />} label="站点管理" child />
<SidebarLink to="/notify-channels" icon={<Bell size={16} />} label="通知渠道" child />
<SidebarLink to="/assistant" icon={<Sparkles size={16} />} label="AI 会话" child />
<SidebarLink to="/license" icon={<KeySquare size={16} />} label="授权许可" child />
<SidebarLink to="/settings" icon={<Sliders size={16} />} label="系统设置" child />
</SidebarGroup>
</>
)}
</nav>
@@ -469,13 +531,62 @@ export function Layout() {
)
}
function SectionHeader({ label, visible }: { label: string; visible: boolean }) {
if (!visible) return <div className="h-5" />;
interface SidebarGroupProps {
id: string;
icon: React.ReactNode;
label: string;
children: React.ReactNode;
collapsed?: boolean;
open?: boolean;
active?: boolean;
onToggle: (id: string) => void;
}
function SidebarGroup({ id, icon, label, children, collapsed, open, active, onToggle }: SidebarGroupProps) {
return (
<div className="px-4 mb-2 mt-4">
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-500">
{label}
</span>
<div className="space-y-1">
<button
type="button"
onClick={() => onToggle(id)}
className={clsx(
'group relative flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-bold transition-all duration-300',
active ? 'bg-gray-950 text-white shadow-sm' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900',
collapsed && 'justify-center px-0',
)}
>
<span className={clsx('flex h-5 w-5 shrink-0 items-center justify-center', active ? 'text-[#c9954a]' : 'text-gray-500 group-hover:text-gray-700')}>
{icon}
</span>
{!collapsed && (
<>
<span className="flex-1 truncate text-left">{label}</span>
<ChevronDown
size={14}
className={clsx('transition-transform duration-200', open && 'rotate-180')}
/>
</>
)}
{collapsed && (
<div className="absolute left-full ml-3 rounded-xl bg-gray-900 px-2.5 py-1.5 text-xs font-semibold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{label}
</div>
)}
</button>
<AnimatePresence initial={false}>
{!collapsed && open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -486,9 +597,10 @@ interface SidebarLinkProps {
label: string;
end?: boolean;
collapsed?: boolean;
child?: boolean;
}
function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
return (
<NavLink
to={to}
@@ -496,6 +608,7 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
className={({ isActive }) =>
clsx(
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 relative group",
child && "py-2.5 text-[13px]",
isActive
? "bg-[#111827] text-white shadow-sm"
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
+3 -2
View File
@@ -291,7 +291,7 @@ const GROUPS: SettingGroup[] = [
{
key: 'cloud-upload',
label: '网盘转存',
description: '把本地媒体复制上传到外部存储。推荐:将 115/夸克挂载到 Alist 后,使用 Alist 作为转存目标。',
description: '把本地媒体复制上传到外部存储。推荐:将 115、123、夸克等挂载到 CloudDrive2 或 Alist 后,使用桥接目标转存。',
items: [
{
key: 'cloud.upload_auto_enabled',
@@ -306,7 +306,8 @@ const GROUPS: SettingGroup[] = [
type: 'select',
defaultValue: 'alist',
options: [
{ value: 'alist', label: 'Alist(推荐,可桥接 115/夸克)' },
{ value: 'clouddrive2', label: 'CloudDrive2(推荐,可桥接 115/123/阿里/夸克)' },
{ value: 'alist', label: 'Alist(可桥接多网盘)' },
{ value: 'webdav', label: 'WebDAV' },
{ value: 'cloud115', label: '115 原生(待接分片上传)' },
{ value: 'quark', label: '夸克原生(待接分片上传)' },
+19 -6
View File
@@ -10,7 +10,7 @@ import {
type StorageType,
} from '../api/storage_config'
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark']
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2']
const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t)
const TYPE_LABEL: Record<string, string> = {
alist: 'ALIST',
@@ -18,6 +18,7 @@ const TYPE_LABEL: Record<string, string> = {
s3: 'S3',
cloud115: '115网盘',
quark: '夸克网盘',
clouddrive2: 'CloudDrive2',
}
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
@@ -34,13 +35,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 / 网盘(115 / 夸克)后端,Cookie 加密存储 + 在线测试,网盘资源通过 302 直链播放
配置 Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放
</p>
</div>
</div>
<div className="flex gap-2 border-b border-gray-200">
{(['alist', 'webdav', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
{(['alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
<button
key={t}
onClick={() => setActive(t)}
@@ -87,6 +88,13 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
clouddrive2: [
{ key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' },
{ key: 'username', label: '用户名' },
{ key: 'password', label: '密码 / Token', secret: true },
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
}
function StorageForm({ type }: { type: StorageType }) {
@@ -226,11 +234,11 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
const [overwrite, setOverwrite] = useState(false)
const [busy, setBusy] = useState(false)
const supported = type === 'alist' || type === 'webdav'
const supported = type === 'alist' || type === 'webdav' || type === 'clouddrive2'
const submit = async () => {
if (!supported) {
toast.error('本地直传目前支持 Alist / WebDAV。115/夸克建议先挂载到 Alist,再通过 Alist 转存。')
toast.error('本地直传目前支持 Alist / WebDAV / CloudDrive2。115/123/夸克建议通过 CloudDrive2 或 Alist 桥接后转存。')
return
}
if (!sourcePath.trim()) {
@@ -274,7 +282,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 / 夸克原生上传需要私有分片上传协议。本版本先支持 Alist / WebDAV:把 115 或夸克挂到 Alist 后,选择 Alist 即可把本地文件转存到对应网盘。
115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 CloudDrive2 或 Alist 后,在这里选择 CloudDrive2 / Alist 转存。
</p>
)}
{type === 'clouddrive2' && (
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
CloudDrive2 已经对接 115、123、阿里、夸克等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。
</p>
)}
<div className="grid gap-3 lg:grid-cols-2">