diff --git a/README.md b/README.md index 09bbfd9..1750567 100644 --- a/README.md +++ b/README.md @@ -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 配置示例 diff --git a/README_EN.md b/README_EN.md index 355cedb..ba8850e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index 1cad468..a753220 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -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()}) diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 1fe5794..50034fc 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -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": "递归扫描源目录"}, diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index 17a3b05..b4693d7 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -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. diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index 572cf80..6f8e384 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -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(` + + + /dav/ + + + + /dav/115/ + 115 + + + /dav/123/Movie.mkv + Movie.mkv789 + +`)) + })) + 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) diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go new file mode 100644 index 0000000..125cee8 --- /dev/null +++ b/internal/service/cloud/clouddrive2.go @@ -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 = ` + + + + + + +` + +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 "" +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index 51bd00d..9392a6c 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -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 diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go index ebff62f..1e2c2af 100644 --- a/internal/service/storage_upload.go +++ b/internal/service/storage_upload.go @@ -24,7 +24,7 @@ const ( CloudUploadSidecarsKey = "cloud.upload_sidecars" CloudUploadOverwriteKey = "cloud.upload_overwrite" CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds" - CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / WebDAV;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": diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go index 01bc432..bb6ebc1 100644 --- a/internal/service/storage_upload_test.go +++ b/internal/service/storage_upload_test.go @@ -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{}) diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 3669258..04410ad 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -1,6 +1,6 @@ import { api } from './client' -export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark' +export type StorageType = 'alist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2' export interface CloudEntry { id: string diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 7d72b7a..6ebd274 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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>({ media: true }) const [profiles, setProfiles] = useState([]) 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 = { + 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() { {/* Navigation List */} -