From fe0e7a09ce02d7b4a0f0cdc87b0d8aa104b955a4 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Tue, 9 Jun 2026 13:57:34 +0800 Subject: [PATCH] fix: improve media file organization workflow --- internal/handler/filemanager.go | 90 ++- internal/handler/organizer.go | 19 +- internal/handler/routes_authenticated.go | 4 + internal/handler/system_extra.go | 6 + internal/service/downloads.go | 73 +- internal/service/downloads_test.go | 42 ++ internal/service/filemanager.go | 350 +++++++--- internal/service/filemanager_test.go | 150 +++++ internal/service/media_classifier.go | 4 + internal/service/organizer.go | 32 +- internal/service/organizer_directory.go | 140 +++- internal/service/organizer_directory_test.go | 253 ++++++- internal/service/organizer_scan.go | 91 +++ internal/service/qbittorrent.go | 5 + internal/service/scheduler.go | 75 ++- internal/service/scheduler_test.go | 107 +++ internal/service/service.go | 3 +- internal/service/transfer.go | 3 +- scripts/deploy.ps1 | 15 + web/src/App.tsx | 9 +- web/src/api/files.ts | 23 +- web/src/api/tools.ts | 33 +- web/src/components/Layout.tsx | 3 +- web/src/pages/AdminPage.tsx | 2 +- web/src/pages/FileManagerPage.tsx | 673 +++++++++++++++++-- web/src/pages/SettingsPage.tsx | 22 +- web/src/pages/StoragePage.tsx | 37 +- web/src/pages/ToolsPage.tsx | 649 ------------------ 28 files changed, 2054 insertions(+), 859 deletions(-) create mode 100644 internal/service/filemanager_test.go create mode 100644 internal/service/organizer_scan.go create mode 100644 internal/service/scheduler_test.go delete mode 100644 web/src/pages/ToolsPage.tsx diff --git a/internal/handler/filemanager.go b/internal/handler/filemanager.go index 334ba49..bf07793 100644 --- a/internal/handler/filemanager.go +++ b/internal/handler/filemanager.go @@ -6,25 +6,95 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/ShukeBta/MediaStationGo/internal/service" ) +type fileFolderReq struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type fileRenameReq struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type fileTransferReq struct { + SourcePath string `json:"source_path"` + DestPath string `json:"dest_path"` + TransferMode string `json:"transfer_mode"` +} + func browseFilesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { path := c.Query("path") max, _ := strconv.Atoi(c.DefaultQuery("max", "1000")) - listing, err := svc.FileManager.List(path, max) - if err != nil { - if errors.Is(err, service.ErrPathOutOfBounds) { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, listing) + recursive := strings.EqualFold(c.DefaultQuery("recursive", "false"), "true") + listing, err := svc.FileManager.List(path, max, recursive) + writeFileManagerResponse(c, listing, err) } } + +func createFolderHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req fileFolderReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := svc.FileManager.CreateFolder(req.Path, req.Name) + writeFileManagerResponse(c, res, err) + } +} + +func renameFileHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req fileRenameReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := svc.FileManager.Rename(req.Path, req.Name) + writeFileManagerResponse(c, res, err) + } +} + +func deleteFileHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Query("path") + if strings.TrimSpace(path) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) + return + } + err := svc.FileManager.Delete(path) + writeFileManagerResponse(c, gin.H{"removed": true}, err) + } +} + +func transferFileHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req fileTransferReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := svc.FileManager.Transfer(req.SourcePath, req.DestPath, service.TransferMode(req.TransferMode)) + writeFileManagerResponse(c, res, err) + } +} + +func writeFileManagerResponse(c *gin.Context, payload any, err error) { + if err != nil { + if errors.Is(err, service.ErrPathOutOfBounds) || errors.Is(err, service.ErrRootMutation) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, payload) +} diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index c548492..8e871a5 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -19,6 +19,10 @@ type organizeReq struct { DestPath string `json:"dest_path"` TargetPath string `json:"target_path"` // deprecated alias for dest_path TransferMode string `json:"transfer_mode"` + MediaType string `json:"media_type"` + ScanAfter bool `json:"scan_after"` + LibraryID string `json:"library_id"` + DryRun bool `json:"dry_run"` } // bindOrganizeOptions parses the optional JSON body into OrganizeOptions. @@ -26,6 +30,10 @@ type organizeReq struct { func bindOrganizeOptions(c *gin.Context) service.OrganizeOptions { var req organizeReq _ = c.ShouldBindJSON(&req) + return organizeOptionsFromReq(req) +} + +func organizeOptionsFromReq(req organizeReq) service.OrganizeOptions { dest := strings.TrimSpace(req.DestPath) if dest == "" { dest = strings.TrimSpace(req.TargetPath) @@ -33,6 +41,8 @@ func bindOrganizeOptions(c *gin.Context) service.OrganizeOptions { opts := service.OrganizeOptions{ SourcePath: strings.TrimSpace(req.SourcePath), DestPath: dest, + MediaType: strings.TrimSpace(req.MediaType), + DryRun: req.DryRun, } if m := strings.TrimSpace(req.TransferMode); m != "" { opts.TransferMode = service.TransferMode(m) @@ -68,7 +78,7 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc { // dir + media dir) so the UI can offer them alongside registered libraries. func organizeSourcesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"sources": svc.Organizer.OrganizeSourceCandidates()}) + c.JSON(http.StatusOK, gin.H{"sources": svc.Organizer.OrganizeSourceCandidates(c.Request.Context())}) } } @@ -76,12 +86,17 @@ func organizeSourcesHandler(svc *service.Container) gin.HandlerFunc { // download directory) into the destination with dedup + 洗版. func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - opts := bindOrganizeOptions(c) + var req organizeReq + _ = c.ShouldBindJSON(&req) + opts := organizeOptionsFromReq(req) res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.ScanAfter && !req.DryRun && svc.Scan != nil { + res.Scans = svc.Scan.ScanLibrariesForPath(c.Request.Context(), res.DestPath, strings.TrimSpace(req.LibraryID)) + } c.JSON(http.StatusOK, res) } } diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index 8d38db3..5a93f6a 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -107,6 +107,10 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * // File browser (used by the library-path picker). authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc)) + authed.POST("/files/folders", middleware.AdminRequired(), createFolderHandler(svc)) + authed.PUT("/files/rename", middleware.AdminRequired(), renameFileHandler(svc)) + authed.DELETE("/files", middleware.AdminRequired(), deleteFileHandler(svc)) + authed.POST("/files/transfer", middleware.AdminRequired(), transferFileHandler(svc)) // Disk usage breakdown. authed.GET("/storage", middleware.AdminRequired(), storageHandler(svc)) diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index fcb723a..b97ebf5 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -78,9 +78,15 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "key": "organize", "label": "整理 & 刮削", "items": []gin.H{ + {"key": "organize.auto", "type": "toggle", "label": "整理源目录定时自动整理"}, {"key": "organizer.auto_after_download", "type": "toggle"}, {"key": "downloads.smart_classify", "type": "toggle"}, {"key": "organizer.smart_classify", "type": "toggle"}, + {"key": "organize.source_dir", "type": "text", "label": "整理源目录"}, + {"key": "organize.target_dir", "type": "text", "label": "整理目的地目录"}, + {"key": "organize.transfer_mode", "type": "select", "label": "默认转移方式"}, + {"key": "organize.interval_seconds", "type": "number", "label": "自动整理间隔秒数"}, + {"key": "organize.keep_seeding", "type": "toggle", "label": "保种"}, {"key": "organize.movie_format", "type": "text"}, {"key": "organize.tv_format", "type": "text"}, {"key": "organize.anime_format", "type": "text"}, diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 1c66561..aff6e96 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -20,7 +20,9 @@ import ( "errors" "math" "net/url" + "os" "path" + "path/filepath" "regexp" "strings" "sync" @@ -40,6 +42,7 @@ type DownloadService struct { hub *Hub qb *QBitClient organizer *OrganizerService + scanner *ScannerService site *SiteService mu sync.Mutex @@ -48,6 +51,10 @@ type DownloadService struct { prevStates map[string]bool // hash -> wasCompleted } +func (d *DownloadService) SetScanner(scanner *ScannerService) { + d.scanner = scanner +} + var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) const settingDownloadClientsManaged = "download_clients.managed" @@ -771,7 +778,7 @@ func (d *DownloadService) poll(ctx context.Context) { d.syncDownloadTaskProgress(ctx, t, taskByKey) if complete && !d.prevStates[hash] { // Just completed: trigger organize - go d.onTorrentComplete(ctx, hash, t.SavePath) + go d.onTorrentComplete(ctx, t) } d.prevStates[hash] = complete } @@ -829,9 +836,11 @@ func findMatchingTaskByIdentity(title string, taskByKey map[string]model.Downloa } // onTorrentComplete handles a torrent that just finished downloading. -// It tries to find the associated Media record and trigger organize. -func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, savePath string) { - if d.organizer == nil || savePath == "" { +// It organizes the completed torrent payload directly. Relying on existing +// Media rows is too late for freshly-downloaded files: they usually have not +// been scanned into the library yet. +func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) { + if d.organizer == nil { return } // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。 @@ -847,19 +856,57 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa } } if !autoOrganize { - d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash)) + d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash)) return } - d.log.Info("download completed, triggering organize", zap.String("hash", hash), zap.String("save_path", savePath)) - // Find Media record by path prefix - var medias []model.Media - if err := d.repo.DB.WithContext(ctx).Where("path LIKE ?", savePath+"%").Find(&medias).Error; err != nil { - d.log.Error("find media by path", zap.Error(err)) + source := d.completedTorrentSource(torrent) + if source == "" { + d.log.Warn("download completed but payload path is not accessible", + zap.String("hash", torrent.Hash), + zap.String("name", torrent.Name), + zap.String("save_path", torrent.SavePath), + zap.String("content_path", torrent.ContentPath)) return } - for i := range medias { - if _, err := d.organizer.OrganizeMedia(ctx, medias[i].ID); err != nil { - d.log.Error("organize media", zap.String("media_id", medias[i].ID), zap.Error(err)) + d.log.Info("download completed, triggering directory organize", + zap.String("hash", torrent.Hash), + zap.String("name", torrent.Name), + zap.String("source", source)) + res, err := d.organizer.OrganizeDirectory(ctx, OrganizeOptions{SourcePath: source}) + if err != nil { + d.log.Error("auto organize completed torrent failed", + zap.String("hash", torrent.Hash), + zap.String("source", source), + zap.Error(err)) + return + } + if d.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" { + res.Scans = d.scanner.ScanLibrariesForPath(ctx, res.DestPath, "") + } + d.log.Info("auto organize completed torrent finished", + zap.String("hash", torrent.Hash), + zap.String("source", source), + zap.String("dest", firstNonEmpty(res.DestPath, "")), + zap.Int("organized", res.Organized), + zap.Int("replaced", res.Replaced), + zap.Int("skipped", res.Skipped), + zap.Int("errors", len(res.Errors))) +} + +func (d *DownloadService) completedTorrentSource(torrent QBitTorrent) string { + for _, candidate := range []string{ + torrent.ContentPath, + filepath.Join(torrent.SavePath, torrent.Name), + torrent.SavePath, + } { + clean := strings.TrimSpace(candidate) + if clean == "" || clean == "." { + continue + } + clean = filepath.Clean(clean) + if _, err := os.Stat(clean); err == nil { + return clean } } + return "" } diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index fbc28d1..0d1415a 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "sync/atomic" @@ -14,6 +15,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -45,6 +47,46 @@ func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) { } } +func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") + dest := filepath.Join(root, "media") + if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(src, []byte("episode"), 0o644); err != nil { + t.Fatal(err) + } + + repos := newOrganizerTestRepo(t) + for key, value := range map[string]string{ + "organizer.auto_after_download": "true", + "organize.target_dir": dest, + "organize.transfer_mode": "copy", + } { + if err := repos.Setting.Set(t.Context(), key, value); err != nil { + t.Fatal(err) + } + } + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) + svc.onTorrentComplete(t.Context(), QBitTorrent{ + Hash: "done123", + Name: "狂飙.S01E01.2023.1080p", + Progress: 1, + SavePath: filepath.Join(root, "downloads", "国产剧"), + ContentPath: src, + }) + + want := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("auto organized file missing at %q: %v", want, err) + } + if _, err := os.Stat(src); err != nil { + t.Fatalf("copy mode should keep source: %v", err) + } +} + func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) { got := publicDownloadTitle("magnet:?xt=urn:btih:abc&dn=%E6%B5%8B%E8%AF%95%E5%BD%B1%E7%89%87") if got != "测试影片" { diff --git a/internal/service/filemanager.go b/internal/service/filemanager.go index 00847d1..2fc30d2 100644 --- a/internal/service/filemanager.go +++ b/internal/service/filemanager.go @@ -1,22 +1,15 @@ -// Package service — server-side file browser. +// Package service — server-side file browser and safe local file operations. // // FileManagerService exposes a strict, allow-listed view of the server's -// filesystem so the React Library / Storage tabs can let the operator -// pick library roots without typing absolute paths from memory. -// -// Allow-list rules: -// -// - Roots: every Library.Path + the configured app.data_dir + -// app.cache_dir, plus the operator-supplied app.media.* defaults. -// - Children must resolve under one of the roots after symlink-free -// filepath.Abs(). Anything else returns ErrPathOutOfBounds. -// -// We never write to the filesystem here; this is read-only browsing. +// filesystem. The design follows MoviePilot's StorageChain boundary: callers +// work with file-item-like records, while this service owns path validation, +// local storage operations, and mutation safety. package service import ( "context" "errors" + "fmt" "os" "path/filepath" "sort" @@ -28,7 +21,7 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) -// FileManagerService browses the server-side filesystem. +// FileManagerService browses and mutates the server-side filesystem. type FileManagerService struct { cfg *config.Config log *zap.Logger @@ -42,11 +35,12 @@ func NewFileManagerService(cfg *config.Config, log *zap.Logger, repo *repository // Entry is one file or directory shown in the browser. type Entry struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - Modified int64 `json:"modified"` + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Modified int64 `json:"modified"` + Ext string `json:"ext,omitempty"` } // Listing describes the contents of a directory plus navigation hints. @@ -63,19 +57,235 @@ type Root struct { Path string `json:"path"` } +type FileOperationResult struct { + Path string `json:"path"` +} + // ErrPathOutOfBounds is returned when path falls outside every allowed root. var ErrPathOutOfBounds = errors.New("path is outside the allowed roots") -// List enumerates a directory under one of the allowed roots, returning -// up to maxEntries items sorted by (dir-first, alphabetical). -func (s *FileManagerService) List(path string, maxEntries int) (*Listing, error) { +// ErrRootMutation protects configured roots such as /media and /downloads. +var ErrRootMutation = errors.New("refusing to mutate an allowed root") + +// List enumerates a directory under one of the allowed roots, returning up to +// maxEntries items sorted by (dir-first, path). Recursive listing is capped by +// maxEntries to avoid accidentally walking huge NAS trees from the UI. +func (s *FileManagerService) List(path string, maxEntries int, recursive ...bool) (*Listing, error) { if maxEntries <= 0 || maxEntries > 5000 { maxEntries = 1000 } - roots, err := s.allowedRoots() + roots, rootList, err := s.allowedRootList() if err != nil { return nil, err } + if strings.TrimSpace(path) == "" { + return &Listing{Path: "", Roots: rootList}, nil + } + + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + if !s.withinAllowed(abs, roots) { + return nil, ErrPathOutOfBounds + } + info, err := os.Stat(abs) + if err != nil { + return nil, err + } + if !info.IsDir() { + return &Listing{Path: abs, Roots: rootList, Entries: []Entry{s.entryFromInfo(abs, info)}}, nil + } + + out := &Listing{Path: abs, Roots: rootList, Entries: []Entry{}} + parent := filepath.Dir(abs) + if parent != abs && s.withinAllowed(parent, roots) { + out.Parent = parent + } + if len(recursive) > 0 && recursive[0] { + if err := s.walkEntries(abs, maxEntries, out); err != nil { + return nil, err + } + sortFileEntries(out.Entries) + return out, nil + } + + entries, err := os.ReadDir(abs) + if err != nil { + return nil, err + } + for _, e := range entries { + if len(out.Entries) >= maxEntries { + break + } + if strings.HasPrefix(e.Name(), ".") { + continue + } + full := filepath.Join(abs, e.Name()) + info, err := e.Info() + if err != nil { + continue + } + out.Entries = append(out.Entries, s.entryFromInfo(full, info)) + } + sortFileEntries(out.Entries) + return out, nil +} + +func (s *FileManagerService) CreateFolder(parent, name string) (*FileOperationResult, error) { + parentPath, roots, err := s.requireAllowedPath(parent, false) + if err != nil { + return nil, err + } + cleanName := sanitizeFilename(name) + if strings.TrimSpace(name) == "" || cleanName == "" || strings.ContainsAny(name, `/\`) { + return nil, errors.New("invalid folder name") + } + dst := filepath.Join(parentPath, cleanName) + if !s.withinAllowed(dst, roots) { + return nil, ErrPathOutOfBounds + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return nil, err + } + return &FileOperationResult{Path: dst}, nil +} + +func (s *FileManagerService) Rename(path, name string) (*FileOperationResult, error) { + src, roots, err := s.requireAllowedPath(path, true) + if err != nil { + return nil, err + } + cleanName := sanitizeFilename(name) + if strings.TrimSpace(name) == "" || cleanName == "" || strings.ContainsAny(name, `/\`) { + return nil, errors.New("invalid name") + } + dst := filepath.Join(filepath.Dir(src), cleanName) + if !s.withinAllowed(dst, roots) { + return nil, ErrPathOutOfBounds + } + if _, err := os.Stat(dst); err == nil { + return nil, fmt.Errorf("target already exists: %s", dst) + } + if err := os.Rename(src, dst); err != nil { + return nil, err + } + return &FileOperationResult{Path: dst}, nil +} + +func (s *FileManagerService) Delete(path string) error { + target, _, err := s.requireAllowedPath(path, true) + if err != nil { + return err + } + return os.RemoveAll(target) +} + +func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferMode) (*FileOperationResult, error) { + src, roots, err := s.requireAllowedPath(sourcePath, true) + if err != nil { + return nil, err + } + dstDir, _, err := s.requireAllowedPath(destDir, false) + if err != nil { + return nil, err + } + info, err := os.Stat(src) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, errors.New("directory transfer is not supported yet") + } + dst := filepath.Join(dstDir, filepath.Base(src)) + if !s.withinAllowed(dst, roots) { + return nil, ErrPathOutOfBounds + } + if _, err := os.Stat(dst); err == nil { + return nil, fmt.Errorf("target already exists: %s", dst) + } + if mode == "" { + mode = TransferCopy + } + if err := transferFile(src, dst, mode); err != nil { + return nil, err + } + return &FileOperationResult{Path: dst}, nil +} + +func (s *FileManagerService) walkEntries(root string, maxEntries int, out *Listing) error { + count := 0 + return filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if full == root { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if count >= maxEntries { + return filepath.SkipAll + } + info, err := d.Info() + if err != nil { + return nil + } + out.Entries = append(out.Entries, s.entryFromInfo(full, info)) + count++ + return nil + }) +} + +func (s *FileManagerService) requireAllowedPath(path string, forbidRoot bool) (string, map[string]string, error) { + roots, _, err := s.allowedRootList() + if err != nil { + return "", nil, err + } + abs, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + return "", nil, err + } + if !s.withinAllowed(abs, roots) { + return "", nil, ErrPathOutOfBounds + } + if forbidRoot && s.isAllowedRoot(abs, roots) { + return "", nil, ErrRootMutation + } + return abs, roots, nil +} + +func (s *FileManagerService) entryFromInfo(path string, info os.FileInfo) Entry { + return Entry{ + Name: filepath.Base(path), + Path: path, + IsDir: info.IsDir(), + Size: info.Size(), + Modified: info.ModTime().Unix(), + Ext: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), + } +} + +func sortFileEntries(entries []Entry) { + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir != entries[j].IsDir { + return entries[i].IsDir + } + return strings.ToLower(entries[i].Path) < strings.ToLower(entries[j].Path) + }) +} + +// allowedRootList returns the union of configured storage roots as +// label → absolute-path plus a sorted UI list. +func (s *FileManagerService) allowedRootList() (map[string]string, []Root, error) { + roots, err := s.allowedRoots() + if err != nil { + return nil, nil, err + } rootList := make([]Root, 0, len(roots)) seen := map[string]struct{}{} for label, p := range roots { @@ -86,62 +296,9 @@ func (s *FileManagerService) List(path string, maxEntries int) (*Listing, error) rootList = append(rootList, Root{Label: label, Path: p}) } sort.Slice(rootList, func(i, j int) bool { return rootList[i].Label < rootList[j].Label }) - - if path == "" { - // Listing the (virtual) root: just hand back the labels. - return &Listing{Path: "", Roots: rootList}, nil - } - - abs, err := filepath.Abs(path) - if err != nil { - return nil, err - } - if !s.withinAllowed(abs, roots) { - return nil, ErrPathOutOfBounds - } - - entries, err := os.ReadDir(abs) - if err != nil { - return nil, err - } - out := &Listing{Path: abs, Roots: rootList} - parent := filepath.Dir(abs) - if parent != abs && s.withinAllowed(parent, roots) { - out.Parent = parent - } - - for i, e := range entries { - if i >= maxEntries { - break - } - name := e.Name() - if strings.HasPrefix(name, ".") { - continue - } - full := filepath.Join(abs, name) - info, err := e.Info() - if err != nil { - continue - } - out.Entries = append(out.Entries, Entry{ - Name: name, - Path: full, - IsDir: e.IsDir(), - Size: info.Size(), - Modified: info.ModTime().Unix(), - }) - } - sort.Slice(out.Entries, func(i, j int) bool { - if out.Entries[i].IsDir != out.Entries[j].IsDir { - return out.Entries[i].IsDir - } - return strings.ToLower(out.Entries[i].Name) < strings.ToLower(out.Entries[j].Name) - }) - return out, nil + return roots, rootList, nil } -// allowedRoots returns the union of {libraries, data_dir, cache_dir, -// media.movies/tv/anime} as label → absolute-path. func (s *FileManagerService) allowedRoots() (map[string]string, error) { roots := map[string]string{} add := func(label, p string) { @@ -162,23 +319,42 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) { add("movies", s.cfg.Media.MoviesDir) add("tv", s.cfg.Media.TVDir) add("anime", s.cfg.Media.AnimeDir) - libs, err := s.repo.Library.List(context.Background()) // librarian list is fast; ctx not propagated from request - if err == nil { - for _, l := range libs { - add("library:"+l.Name, l.Path) + add("downloads", envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")) + add("media", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")) + if s.repo != nil && s.repo.Setting != nil { + addSetting := func(label, key string) { + if value, err := s.repo.Setting.Get(context.Background(), key); err == nil { + add(label, strings.TrimSpace(value)) + } + } + addSetting("organize-source", "organize.source_dir") + addSetting("organize-target", "organize.target_dir") + addSetting("qb-savepath", "qbittorrent.savepath") + } + if s.repo != nil && s.repo.Library != nil { + libs, err := s.repo.Library.List(context.Background()) + if err == nil { + for _, l := range libs { + add("library:"+l.Name, l.Path) + } } } return roots, nil } -// withinAllowed reports whether path lives under any allowed root. func (s *FileManagerService) withinAllowed(path string, roots map[string]string) bool { - for _, r := range roots { - rel, err := filepath.Rel(r, path) - if err != nil { - continue - } - if !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + for _, root := range roots { + if pathWithin(path, root) { + return true + } + } + return false +} + +func (s *FileManagerService) isAllowedRoot(path string, roots map[string]string) bool { + path = filepath.Clean(path) + for _, root := range roots { + if strings.EqualFold(path, filepath.Clean(root)) { return true } } diff --git a/internal/service/filemanager_test.go b/internal/service/filemanager_test.go new file mode 100644 index 0000000..a6852f9 --- /dev/null +++ b/internal/service/filemanager_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func newFileManagerTestServiceWithRepo(t *testing.T, root string) (*FileManagerService, *repository.Container) { + t.Helper() + 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{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "downloads", Path: root, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.App.DataDir = root + cfg.Cache.CacheDir = root + return NewFileManagerService(cfg, zap.NewNop(), repos), repos +} + +func newFileManagerTestService(t *testing.T, root string) *FileManagerService { + t.Helper() + svc, _ := newFileManagerTestServiceWithRepo(t, root) + return svc +} + +func TestFileManagerRecursiveListAndMutations(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "downloads", "国产剧") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(nested, "狂飙.S01E01.mkv") + if err := os.WriteFile(mediaPath, []byte("video"), 0o644); err != nil { + t.Fatal(err) + } + svc := newFileManagerTestService(t, root) + + listing, err := svc.List(root, 100, true) + if err != nil { + t.Fatal(err) + } + found := false + for _, entry := range listing.Entries { + if entry.Path == mediaPath { + found = true + break + } + } + if !found { + t.Fatalf("recursive listing did not include %s", mediaPath) + } + + created, err := svc.CreateFolder(root, "整理目标") + if err != nil { + t.Fatal(err) + } + emptyListing, err := svc.List(created.Path, 100, false) + if err != nil { + t.Fatal(err) + } + if emptyListing.Entries == nil || len(emptyListing.Entries) != 0 { + t.Fatalf("empty directory entries = %#v, want empty slice", emptyListing.Entries) + } + copied, err := svc.Transfer(mediaPath, created.Path, TransferCopy) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(copied.Path); err != nil { + t.Fatalf("copied file missing: %v", err) + } + renamed, err := svc.Rename(copied.Path, "狂飙 - S01E01.mkv") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(renamed.Path); err != nil { + t.Fatalf("renamed file missing: %v", err) + } + if err := svc.Delete(renamed.Path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(renamed.Path); !os.IsNotExist(err) { + t.Fatalf("expected file deleted, stat err=%v", err) + } +} + +func TestFileManagerRefusesRootMutation(t *testing.T) { + root := t.TempDir() + svc := newFileManagerTestService(t, root) + if err := svc.Delete(root); !errors.Is(err, ErrRootMutation) { + t.Fatalf("Delete(root) err = %v, want ErrRootMutation", err) + } +} + +func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "downloads") + targetDir := filepath.Join(root, "media") + qbDir := filepath.Join(root, "qb-save") + for _, dir := range []string{sourceDir, targetDir, qbDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + svc, repos := newFileManagerTestServiceWithRepo(t, root) + if err := repos.Setting.Set(t.Context(), "organize.source_dir", sourceDir); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organize.target_dir", targetDir); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", qbDir); err != nil { + t.Fatal(err) + } + + listing, err := svc.List("", 100) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, root := range listing.Roots { + got[root.Label] = root.Path + } + for label, want := range map[string]string{ + "organize-source": filepath.Clean(sourceDir), + "organize-target": filepath.Clean(targetDir), + "qb-savepath": filepath.Clean(qbDir), + } { + if got[label] != want { + t.Fatalf("root %s = %q, want %q; roots=%#v", label, got[label], want, listing.Roots) + } + } +} diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 3e54269..1994fde 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -114,9 +114,13 @@ func normalizeMediaType(mediaType, title, category string) string { return "anime" case "variety": return "variety" + case "adult", "nsfw": + return "adult" } text := strings.ToLower(title + " " + category) switch { + case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): + return "adult" case strings.Contains(text, "movie") || strings.Contains(text, "电影"): return "movie" case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"): diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 1b320ca..05bff60 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -49,10 +49,25 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } // OrganizeResult reports what happened. type OrganizeResult struct { - Organized int `json:"organized"` - Skipped int `json:"skipped"` - Replaced int `json:"replaced,omitempty"` - Errors []string `json:"errors,omitempty"` + Organized int `json:"organized"` + Skipped int `json:"skipped"` + Replaced int `json:"replaced,omitempty"` + Errors []string `json:"errors,omitempty"` + SourcePath string `json:"source_path,omitempty"` + DestPath string `json:"dest_path,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Items []OrganizePreviewItem `json:"items,omitempty"` + Scans []OrganizeScanSummary `json:"scans,omitempty"` +} + +type OrganizePreviewItem struct { + Source string `json:"source"` + Target string `json:"target,omitempty"` + Action string `json:"action"` // organize / skip / replace / error + Reason string `json:"reason,omitempty"` + MediaType string `json:"media_type,omitempty"` + Category string `json:"category,omitempty"` + Title string `json:"title,omitempty"` } // OrganizeOptions carries per-request overrides for an organize operation. @@ -69,6 +84,10 @@ type OrganizeOptions struct { DestPath string // TransferMode 本次整理的转移方式,覆盖 organize.transfer_mode 设置。 TransferMode TransferMode + // MediaType 手动整理时由 UI 指定的媒体类型。空值时按文件名/目录推断。 + MediaType string + // DryRun 仅生成整理预览,不实际移动/复制/硬链接文件。 + DryRun bool } // OrganizeMedia moves a single media file into the target library directory. @@ -394,9 +413,6 @@ func sanitizeFilename(s string) string { } func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) string { - if strings.TrimSpace(category) == "" { - return libraryPath - } typeDir := mediaTypeRootDir(mediaType) if typeDir == "" || pathAlreadyEndsWith(libraryPath, typeDir) { return libraryPath @@ -433,6 +449,8 @@ func mediaTypeRootDir(mediaType string) string { return "电影" case "tv", "anime", "variety": return "电视剧" + case "adult": + return "成人" default: return "" } diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index 96fff28..50f69cd 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -40,7 +40,7 @@ type OrganizeSourceCandidate struct { // OrganizeSourceCandidates returns the configured directories that are valid // organize sources (download dir + media dir). It uses the container-visible // paths; in NAS direct-read mode those equal the host paths the operator sees. -func (o *OrganizerService) OrganizeSourceCandidates() []OrganizeSourceCandidate { +func (o *OrganizerService) OrganizeSourceCandidates(ctx context.Context) []OrganizeSourceCandidate { out := []OrganizeSourceCandidate{} seen := map[string]struct{}{} add := func(label, path, kind string) { @@ -49,17 +49,48 @@ func (o *OrganizerService) OrganizeSourceCandidates() []OrganizeSourceCandidate return } clean := filepath.Clean(path) + if !isAccessibleDir(clean) { + return + } if _, ok := seen[clean]; ok { return } seen[clean] = struct{}{} out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind}) } + add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source") + add("下载器保存目录", o.settingValue(ctx, "qbittorrent.savepath"), "download") add("下载目录", envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download") add("媒体目录", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media"), "media") return out } +func (o *OrganizerService) settingValue(ctx context.Context, key string) string { + if o.repo == nil || o.repo.Setting == nil { + return "" + } + if v, err := o.repo.Setting.Get(ctx, key); err == nil { + return strings.TrimSpace(v) + } + return "" +} + +// defaultSourceRoot resolves the source root for a directory organize: +// explicit override → organize.source_dir setting → qB default save path → +// download container dir. +func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string { + if r := strings.TrimSpace(override); r != "" { + return r + } + if v := o.settingValue(ctx, "organize.source_dir"); v != "" { + return v + } + if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" { + return v + } + return envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads") +} + // defaultDestRoot resolves the destination root for a directory organize: // explicit override → organize.target_dir setting → media container dir. func (o *OrganizerService) defaultDestRoot(ctx context.Context, override string) string { @@ -77,12 +108,13 @@ func (o *OrganizerService) defaultDestRoot(ctx context.Context, override string) // OrganizeDirectory organizes every video file found under opts.SourcePath into // the destination root, applying dedup + 洗版 (resolution replacement). func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeOptions) (*OrganizeResult, error) { - source := strings.TrimSpace(opts.SourcePath) + source := strings.TrimSpace(o.defaultSourceRoot(ctx, opts.SourcePath)) if source == "" { return nil, errors.New("source path required") } source = filepath.Clean(source) - if info, err := os.Stat(source); err != nil || !info.IsDir() { + info, statErr := os.Stat(source) + if statErr != nil { return nil, fmt.Errorf("source directory not accessible: %s", source) } dest := filepath.Clean(o.defaultDestRoot(ctx, opts.DestPath)) @@ -90,7 +122,26 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO return nil, errors.New("destination path required") } mode := o.resolveTransferMode(ctx, opts.TransferMode) - res := &OrganizeResult{} + res := &OrganizeResult{SourcePath: source, DestPath: dest, DryRun: opts.DryRun} + if !info.IsDir() { + ext := strings.ToLower(filepath.Ext(source)) + if _, ok := videoExtensions[ext]; !ok { + return nil, fmt.Errorf("source is not a supported video file: %s", source) + } + if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.DryRun, res); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(source), err.Error())) + res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "error", Reason: err.Error()}) + } + o.log.Info("organize file finished", + zap.String("source", source), + zap.String("dest", dest), + zap.String("mode", string(mode)), + zap.Int("organized", res.Organized), + zap.Int("replaced", res.Replaced), + zap.Int("skipped", res.Skipped), + ) + return res, nil + } walkErr := walk(source, func(path string, wi walkInfo) error { if wi.isDir { return nil @@ -99,8 +150,9 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO if _, ok := videoExtensions[ext]; !ok { return nil } - if err := o.organizeSourceFile(ctx, path, source, dest, mode, res); err != nil { + if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.DryRun, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error())) + res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "error", Reason: err.Error()}) } return nil }) @@ -125,7 +177,7 @@ type organizeDirectoryLayout struct { // organizeSourceFile organizes a single video file from the source directory // into destRoot, applying dedup + 洗版. -func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, res *OrganizeResult) error { +func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride string, dryRun bool, res *OrganizeResult) error { ext := filepath.Ext(src) title, year := CleanQuery(src) if title == "" { @@ -140,14 +192,29 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo } season, episode := ParseEpisode(src) layout := o.inferOrganizeDirectoryLayout(src, sourceRoot) + if forced := normalizeOrganizeMediaType(mediaTypeOverride); forced != "" { + if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forced { + layout.Category = "" + } + layout.MediaType = forced + } + if layout.MediaType == "" { + layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode) + } layoutRoot := destRoot + if layout.MediaType != "" { + layoutRoot = o.organizeRoot(destRoot, layout.MediaType, layout.Category) + } if layout.Category != "" { - root := o.organizeRoot(destRoot, layout.MediaType, layout.Category) - layoutRoot = categoryRoot(root, sanitizeFilename(layout.Category)) + layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) } var destDir, dst, episodeTag string - if season > 0 || episode > 0 { + isSeries := season > 0 || episode > 0 + if layout.MediaType != "" { + isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0) + } + if isSeries { // TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext episodeTag = fmt.Sprintf("S%02dE%02d", season, episode) destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season)) @@ -165,6 +232,10 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo // 源文件已经位于目标位置:无需处理。 if filepath.Clean(src) == filepath.Clean(dst) { res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{ + Source: src, Target: dst, Action: "skip", Reason: "already organized", + MediaType: layout.MediaType, Category: layout.Category, Title: title, + }) return nil } @@ -182,6 +253,14 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo // 洗版:仅当来源与已存在版本的分辨率都可判定、且来源更高时才替换; // 任一方分辨率未知时保守跳过,绝不删除无法判定的已存在文件。 if srcArea > 0 && bestArea > 0 && srcArea > bestArea { + res.Items = append(res.Items, OrganizePreviewItem{ + Source: src, Target: dst, Action: "replace", Reason: "higher resolution", + MediaType: layout.MediaType, Category: layout.Category, Title: title, + }) + if dryRun { + res.Replaced++ + return nil + } if err := o.replaceVersions(ctx, src, existing, dst, mode); err != nil { return err } @@ -198,14 +277,30 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo o.log.Debug("organize skip duplicate", zap.String("src", src), zap.String("dest_dir", destDir)) res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{ + Source: src, Target: dst, Action: "skip", Reason: "duplicate exists", + MediaType: layout.MediaType, Category: layout.Category, Title: title, + }) return nil } + res.Items = append(res.Items, OrganizePreviewItem{ + Source: src, Target: dst, Action: "organize", + MediaType: layout.MediaType, Category: layout.Category, Title: title, + }) + if dryRun { + res.Organized++ + return nil + } if err := os.MkdirAll(destDir, 0o755); err != nil { return err } if _, err := os.Stat(dst); err == nil { res.Skipped++ + if len(res.Items) > 0 { + res.Items[len(res.Items)-1].Action = "skip" + res.Items[len(res.Items)-1].Reason = "target exists" + } return nil } if err := transferFile(src, dst, mode); err != nil { @@ -219,6 +314,30 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo return nil } +func normalizeOrganizeMediaType(mediaType string) string { + switch strings.ToLower(strings.TrimSpace(mediaType)) { + case "movie", "film": + return "movie" + case "tv", "series", "show", "drama": + return "tv" + case "anime", "animation": + return "anime" + case "variety": + return "variety" + case "adult", "nsfw": + return "adult" + default: + return "" + } +} + +func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string { + if season > 0 || episode > 0 { + return "tv" + } + return normalizeMediaType("", title, src) +} + func (o *OrganizerService) inferOrganizeDirectoryLayout(src, sourceRoot string) organizeDirectoryLayout { for _, name := range organizeDirectoryCategoryCandidates(src, sourceRoot) { if mediaType, category := o.mediaTypeForDirectoryCategory(name); mediaType != "" && category != "" { @@ -302,6 +421,9 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory addConfigured("documentary", "纪录片", "tv") addConfigured("children", "儿童", "tv") addConfigured("uncategorized_tv", "未分类", "tv") + addConfigured("adult", "成人", "adult") + addConfigured("adult_9kg", "9KG", "adult") + addConfigured("adult_jav", "番号", "adult") return out } diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index f3f315b..a018c05 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -42,12 +42,199 @@ func TestOrganizeDirectoryNewMedia(t *testing.T) { if res.Organized != 1 || res.Replaced != 0 || res.Skipped != 0 { t.Fatalf("expected organized=1 replaced=0 skipped=0, got %+v", res) } - want := filepath.Join(dest, "Dune (2021)", "Dune (2021).mkv") + want := filepath.Join(dest, "电影", "Dune (2021)", "Dune (2021).mkv") if _, err := os.Stat(want); err != nil { t.Fatalf("expected organized file at %q: %v", want, err) } } +func TestOrganizeDirectoryDryRunReturnsPreviewWithoutWriting(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "Dune 2021 2160p WEB-DL.mkv") + writeOrgFile(t, sourceFile, "dune-uhd") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + DryRun: true, + }) + if err != nil { + t.Fatalf("organize directory dry-run: %v", err) + } + if !res.DryRun || res.Organized != 1 || len(res.Items) != 1 { + t.Fatalf("unexpected dry-run result: %+v", res) + } + want := filepath.Join(dest, "电影", "Dune (2021)", "Dune (2021).mkv") + if res.Items[0].Source != sourceFile || res.Items[0].Target != want || res.Items[0].Action != "organize" { + t.Fatalf("preview item = %#v, want source=%q target=%q action=organize", res.Items[0], sourceFile, want) + } + if _, err := os.Stat(want); !os.IsNotExist(err) { + t.Fatalf("dry-run should not create %q, stat err=%v", want, err) + } +} + +func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "Dune 2021 2160p WEB-DL.mkv"), "dune-uhd") + + repos := newOrganizerTestRepo(t) + if err := repos.Setting.Set(t.Context(), "organize.source_dir", src); err != nil { + t.Fatal(err) + } + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory with configured source: %v", err) + } + if res.SourcePath != filepath.Clean(src) || res.Organized != 1 { + t.Fatalf("result = %+v, want source=%q organized=1", res, src) + } +} + +func TestOrganizeDirectoryAcceptsSingleVideoFileSource(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads", "Dune 2021 2160p WEB-DL.mkv") + dest := filepath.Join(root, "media") + writeOrgFile(t, src, "dune-uhd") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize single file: %v", err) + } + if res.Organized != 1 { + t.Fatalf("result = %+v, want organized=1", res) + } + want := filepath.Join(dest, "电影", "Dune (2021)", "Dune (2021).mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected organized file at %q: %v", want, err) + } +} + +func TestOrganizeDirectoryHonorsManualMediaType(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "Some Show S01E01 2024 1080p.mkv"), "show-e01") + writeOrgFile(t, filepath.Join(src, "Some Movie 2024 1080p.mkv"), "movie") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + tvRes, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: filepath.Join(src, "Some Show S01E01 2024 1080p.mkv"), + DestPath: dest, + MediaType: "tv", + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize manual tv type: %v", err) + } + if tvRes.Organized != 1 { + t.Fatalf("tv result = %+v, want organized=1", tvRes) + } + tvWant := filepath.Join(dest, "电视剧", "Some Show", "Season 01", "Some Show - S01E01.mkv") + if _, err := os.Stat(tvWant); err != nil { + t.Fatalf("expected tv file at %q: %v", tvWant, err) + } + + movieRes, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: filepath.Join(src, "Some Movie 2024 1080p.mkv"), + DestPath: dest, + MediaType: "movie", + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize manual movie type: %v", err) + } + if movieRes.Organized != 1 { + t.Fatalf("movie result = %+v, want organized=1", movieRes) + } + movieWant := filepath.Join(dest, "电影", "Some Movie (2024)", "Some Movie (2024).mkv") + if _, err := os.Stat(movieWant); err != nil { + t.Fatalf("expected movie file at %q: %v", movieWant, err) + } +} + +func TestOrganizeDirectoryHonorsAdultMediaTypeRoot(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads", "ABP-123.mkv") + dest := filepath.Join(root, "media") + writeOrgFile(t, src, "adult") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + MediaType: "adult", + TransferMode: TransferCopy, + DryRun: true, + }) + if err != nil { + t.Fatalf("organize adult type: %v", err) + } + if res.Organized != 1 || len(res.Items) != 1 { + t.Fatalf("result = %+v, want one preview item", res) + } + if !pathWithin(res.Items[0].Target, filepath.Join(dest, "成人")) { + t.Fatalf("adult target = %q, want under %q", res.Items[0].Target, filepath.Join(dest, "成人")) + } + if res.Items[0].MediaType != "adult" { + t.Fatalf("media type = %q, want adult", res.Items[0].MediaType) + } +} + +func TestOrganizeSourceCandidatesOnlyReturnAccessibleDirectories(t *testing.T) { + root := t.TempDir() + configuredDir := filepath.Join(root, "configured-downloads") + downloadDir := filepath.Join(root, "downloads") + mediaDir := filepath.Join(root, "media") + if err := os.MkdirAll(configuredDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(downloadDir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", downloadDir) + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", filepath.Join(root, "missing-media")) + + repos := newOrganizerTestRepo(t) + if err := repos.Setting.Set(t.Context(), "organize.source_dir", configuredDir); err != nil { + t.Fatal(err) + } + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + candidates := org.OrganizeSourceCandidates(t.Context()) + if len(candidates) != 2 { + t.Fatalf("candidates = %#v, want configured source + accessible download dir", candidates) + } + if candidates[0].Path != filepath.Clean(configuredDir) || candidates[0].Kind != "source" { + t.Fatalf("first candidate = %#v, want configured source %q", candidates[0], configuredDir) + } + if candidates[1].Path != filepath.Clean(downloadDir) || candidates[1].Kind != "download" { + t.Fatalf("second candidate = %#v, want accessible download dir %q", candidates[1], downloadDir) + } + + if err := os.MkdirAll(mediaDir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", mediaDir) + candidates = org.OrganizeSourceCandidates(t.Context()) + if len(candidates) != 3 { + t.Fatalf("candidates = %#v, want configured source, download and media dirs", candidates) + } +} + // TestOrganizeDirectoryDedup verifies that media already present in the // destination is NOT organized again from the source (去重), and the existing // file is left untouched. @@ -59,7 +246,7 @@ func TestOrganizeDirectoryDedup(t *testing.T) { // Source release at 1080p. writeOrgFile(t, filepath.Join(src, "The Matrix 1999 1080p BluRay.mkv"), "matrix-source") // Destination already holds the organized 1080p version. - existing := filepath.Join(dest, "The Matrix (1999)", "The Matrix (1999).mkv") + existing := filepath.Join(dest, "电影", "The Matrix (1999)", "The Matrix (1999).mkv") writeOrgFile(t, existing, "matrix-existing") repos := newOrganizerTestRepo(t) @@ -98,7 +285,7 @@ func TestOrganizeDirectoryReplaceHigherResolution(t *testing.T) { // Source is 2160p; filename token drives resolutionArea when no DB row. writeOrgFile(t, filepath.Join(src, "Inception 2010 2160p BluRay.mkv"), "inception-uhd") // Destination already holds an organized 1080p version (scanned dims). - existing := filepath.Join(dest, "Inception (2010)", "Inception (2010).mkv") + existing := filepath.Join(dest, "电影", "Inception (2010)", "Inception (2010).mkv") writeOrgFile(t, existing, "inception-1080p") repos := newOrganizerTestRepo(t) @@ -143,7 +330,7 @@ func TestOrganizeDirectoryKeepsHigherResolutionExisting(t *testing.T) { dest := filepath.Join(root, "media") writeOrgFile(t, filepath.Join(src, "Inception 2010 720p.mkv"), "inception-720p") - existing := filepath.Join(dest, "Inception (2010)", "Inception (2010).mkv") + existing := filepath.Join(dest, "电影", "Inception (2010)", "Inception (2010).mkv") writeOrgFile(t, existing, "inception-2160p") repos := newOrganizerTestRepo(t) @@ -177,7 +364,7 @@ func TestOrganizeDirectoryTVEpisodeDedup(t *testing.T) { dest := filepath.Join(root, "media") writeOrgFile(t, filepath.Join(src, "Friends S01E01 1080p.mkv"), "friends-s01e01-src") - existing := filepath.Join(dest, "Friends", "Season 01", "Friends - S01E01.mkv") + existing := filepath.Join(dest, "电视剧", "Friends", "Season 01", "Friends - S01E01.mkv") writeOrgFile(t, existing, "friends-s01e01-existing") // A different episode that should still be organized fresh. writeOrgFile(t, filepath.Join(src, "Friends S01E02 1080p.mkv"), "friends-s01e02-src") @@ -204,7 +391,7 @@ func TestOrganizeDirectoryTVEpisodeDedup(t *testing.T) { if got, err := os.ReadFile(existing); err != nil || string(got) != "friends-s01e01-existing" { t.Fatalf("existing E01 must be untouched, got %q err=%v", string(got), err) } - e02 := filepath.Join(dest, "Friends", "Season 01", "Friends - S01E02.mkv") + e02 := filepath.Join(dest, "电视剧", "Friends", "Season 01", "Friends - S01E02.mkv") if _, err := os.Stat(e02); err != nil { t.Fatalf("expected E02 organized at %q: %v", e02, err) } @@ -239,3 +426,57 @@ func TestOrganizeDirectoryUsesDownloadCategoryLayout(t *testing.T) { t.Fatalf("expected movie organized at %q: %v", movie, err) } } + +func TestOrganizeDirectoryScanAfterRecursesNestedDownloadFolders(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "国产剧", "子目录", "狂飙.S01E01.2023.1080p.WEB-DL.mkv"), "kuangbiao-e01") + writeOrgFile(t, filepath.Join(src, "华语电影", "更深", "流浪地球2.2023.2160p.WEB-DL.H265.mkv"), "wandering-earth-2") + + repos := newOrganizerTestRepo(t) + tvLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + movieLib := model.Library{Name: "华语电影", Path: filepath.Join(dest, "电影", "华语电影"), Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &tvLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &movieLib); err != nil { + t.Fatal(err) + } + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 2 { + t.Fatalf("organized = %d, want 2", res.Organized) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scans := scanner.ScanLibrariesForPath(t.Context(), res.DestPath, "") + if len(scans) != 2 { + t.Fatalf("scans = %#v, want two matching libraries", scans) + } + added := 0 + for _, scan := range scans { + if scan.Error != "" { + t.Fatalf("scan failed: %#v", scan) + } + added += scan.Added + } + if added != 2 { + t.Fatalf("scan added = %d, want 2", added) + } + var count int64 + if err := repos.DB.Model(&model.Media{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("media rows = %d, want 2", count) + } +} diff --git a/internal/service/organizer_scan.go b/internal/service/organizer_scan.go new file mode 100644 index 0000000..62ecd8a --- /dev/null +++ b/internal/service/organizer_scan.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// OrganizeScanSummary reports a library scan triggered after directory +// organize. It is intentionally compact for the tools page toast. +type OrganizeScanSummary struct { + LibraryID string `json:"library_id"` + Name string `json:"name"` + Path string `json:"path"` + Visited int `json:"visited"` + Added int `json:"added"` + Updated int `json:"updated"` + Removed int64 `json:"removed"` + Error string `json:"error,omitempty"` +} + +// ScanLibrariesForPath recursively scans libraries affected by an organize +// destination. If preferredLibraryID is set, only that library is scanned. +// Otherwise every enabled library whose path intersects destRoot is scanned; +// if no path can be matched, we fall back to all enabled libraries to preserve +// the old "scan all after ingest" UI behavior. +func (s *ScannerService) ScanLibrariesForPath(ctx context.Context, destRoot, preferredLibraryID string) []OrganizeScanSummary { + if s == nil || s.repo == nil || s.repo.Library == nil { + return nil + } + libraries, err := s.repo.Library.List(ctx) + if err != nil { + return []OrganizeScanSummary{{Error: err.Error()}} + } + targets := selectOrganizeScanTargets(libraries, destRoot, preferredLibraryID) + out := make([]OrganizeScanSummary, 0, len(targets)) + for _, lib := range targets { + summary := OrganizeScanSummary{ + LibraryID: lib.ID, + Name: lib.Name, + Path: lib.Path, + } + res, err := s.ScanLibrary(ctx, lib.ID) + if err != nil { + summary.Error = err.Error() + out = append(out, summary) + continue + } + summary.Visited = res.Visited + summary.Added = res.Added + summary.Updated = res.Updated + summary.Removed = res.Removed + out = append(out, summary) + } + return out +} + +func selectOrganizeScanTargets(libraries []model.Library, destRoot, preferredLibraryID string) []model.Library { + preferredLibraryID = strings.TrimSpace(preferredLibraryID) + enabled := make([]model.Library, 0, len(libraries)) + for _, lib := range libraries { + if !lib.Enabled { + continue + } + if preferredLibraryID != "" { + if lib.ID == preferredLibraryID { + return []model.Library{lib} + } + continue + } + enabled = append(enabled, lib) + } + if preferredLibraryID != "" { + return nil + } + destRoot = strings.TrimSpace(destRoot) + if destRoot == "" { + return enabled + } + matched := make([]model.Library, 0, len(enabled)) + for _, lib := range enabled { + if pathWithin(lib.Path, destRoot) || pathWithin(destRoot, lib.Path) { + matched = append(matched, lib) + } + } + if len(matched) > 0 { + return matched + } + return enabled +} diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index ca2f5f7..48f5b4c 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -54,6 +54,11 @@ type QBitTorrent struct { NumLeech int `json:"num_leechs"` Size int64 `json:"size"` SavePath string `json:"save_path"` + // ContentPath is qBittorrent's resolved payload path. For single-file + // torrents it points at the file; for multi-file torrents it points at the + // root folder. Prefer it for automatic organize so we do not scan the whole + // download category. + ContentPath string `json:"content_path"` } // QBitClient is a thread-safe qBittorrent v2 API client. diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index f612c70..db5c161 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -25,6 +25,7 @@ package service import ( "context" + "strconv" "strings" "sync" "time" @@ -42,6 +43,7 @@ type SchedulerService struct { repo *repository.Container scanner *ScannerService transcoder *TranscoderService + organizer *OrganizerService hub *Hub cacheDir string @@ -59,12 +61,15 @@ type scheduledJob struct { lastErr string } +type schedulerManualRunKey struct{} + // NewSchedulerService is the constructor. func NewSchedulerService( log *zap.Logger, repo *repository.Container, scanner *ScannerService, transcoder *TranscoderService, + organizer *OrganizerService, hub *Hub, cacheDir string, ) *SchedulerService { @@ -73,6 +78,7 @@ func NewSchedulerService( repo: repo, scanner: scanner, transcoder: transcoder, + organizer: organizer, hub: hub, cacheDir: cacheDir, stopCh: make(chan struct{}), @@ -87,6 +93,11 @@ func (s *SchedulerService) Start(ctx context.Context) { interval: 60 * time.Minute, run: s.jobScanLibraries, }, + { + name: "organize_source", + interval: s.organizeSourceInterval(ctx), + run: s.jobOrganizeSource, + }, { name: "transcode_cleanup", interval: 24 * time.Hour, @@ -143,7 +154,7 @@ func (s *SchedulerService) Status() []JobStatus { func (s *SchedulerService) RunNow(ctx context.Context, name string) error { for _, j := range s.jobs { if j.name == name { - return s.runOnce(ctx, j) + return s.runOnce(context.WithValue(ctx, schedulerManualRunKey{}, true), j) } } return nil @@ -227,12 +238,66 @@ func (s *SchedulerService) periodicScanEnabled(ctx context.Context) bool { if err != nil { return false } - switch strings.ToLower(strings.TrimSpace(v)) { - case "1", "true", "yes", "on", "enabled": - return true - default: + return parseBoolSetting(v, false) +} + +// jobOrganizeSource periodically organizes the configured staging/download +// source directory into the configured media destination. It is intentionally +// opt-in: manual file management remains available, but background disk walking +// only starts after the operator enables organize.auto. +func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error { + manual, _ := ctx.Value(schedulerManualRunKey{}).(bool) + if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) { + return nil + } + res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{}) + if err != nil { + return err + } + if s.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" { + res.Scans = s.scanner.ScanLibrariesForPath(ctx, res.DestPath, "") + } + if s.log != nil && res != nil { + s.log.Info("scheduled source organize finished", + zap.String("source", res.SourcePath), + zap.String("dest", res.DestPath), + zap.Int("organized", res.Organized), + zap.Int("replaced", res.Replaced), + zap.Int("skipped", res.Skipped), + zap.Int("errors", len(res.Errors)), + ) + } + return nil +} + +func (s *SchedulerService) autoOrganizeSourceEnabled(ctx context.Context) bool { + if s.repo == nil || s.repo.Setting == nil { return false } + v, err := s.repo.Setting.Get(ctx, "organize.auto") + if err != nil { + return false + } + return parseBoolSetting(v, false) +} + +func (s *SchedulerService) organizeSourceInterval(ctx context.Context) time.Duration { + const fallback = 5 * time.Minute + if s.repo == nil || s.repo.Setting == nil { + return fallback + } + v, err := s.repo.Setting.Get(ctx, "organize.interval_seconds") + if err != nil { + return fallback + } + seconds, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || seconds <= 0 { + return fallback + } + if seconds < 60 { + seconds = 60 + } + return time.Duration(seconds) * time.Second } // jobCleanTranscodeCache deletes HLS artefacts older than 24h. diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go new file mode 100644 index 0000000..d3bc96c --- /dev/null +++ b/internal/service/scheduler_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "国产剧", "狂飙.S01E01.2023.1080p.mkv") + writeOrgFile(t, sourceFile, "episode") + + repos := newOrganizerTestRepo(t) + if err := repos.Setting.Set(t.Context(), "organize.source_dir", src); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organize.target_dir", dest); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organize.transfer_mode", "copy"); err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") + if err := scheduler.jobOrganizeSource(t.Context()); err != nil { + t.Fatalf("disabled organize source job should be a no-op: %v", err) + } + + want := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") + if _, err := os.Stat(want); !os.IsNotExist(err) { + t.Fatalf("disabled job should not create %q, stat err=%v", want, err) + } +} + +func TestSchedulerOrganizeSourceUsesConfiguredSourceAndDestination(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "国产剧", "狂飙.S01E01.2023.1080p.mkv"), "episode") + + repos := newOrganizerTestRepo(t) + for key, value := range map[string]string{ + "organize.auto": "true", + "organize.source_dir": src, + "organize.target_dir": dest, + "organize.transfer_mode": "copy", + } { + if err := repos.Setting.Set(t.Context(), key, value); err != nil { + t.Fatal(err) + } + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") + if err := scheduler.jobOrganizeSource(t.Context()); err != nil { + t.Fatalf("organize source job: %v", err) + } + + want := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected scheduled organize output at %q: %v", want, err) + } +} + +func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "Dune 2021 1080p.mkv"), "movie") + + repos := newOrganizerTestRepo(t) + for key, value := range map[string]string{ + "organize.source_dir": src, + "organize.target_dir": dest, + "organize.transfer_mode": "copy", + } { + if err := repos.Setting.Set(t.Context(), key, value); err != nil { + t.Fatal(err) + } + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") + scheduler.jobs = []*scheduledJob{{ + name: "organize_source", + interval: time.Minute, + run: scheduler.jobOrganizeSource, + }} + + if err := scheduler.RunNow(t.Context(), "organize_source"); err != nil { + t.Fatalf("run now organize source: %v", err) + } + + want := filepath.Join(dest, "电影", "Dune (2021)", "Dune (2021).mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected run-now organize output at %q: %v", want, err) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index a91ebff..a58eab1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -116,7 +116,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont downloadClients := NewDownloadClientService(log, repos) assistant := NewAssistantService(log, repos, ai) douban := NewDoubanProvider(cfg, log) - scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir) + scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, hub, cfg.Cache.CacheDir) // 初始化认证相关服务 tokenSvc := NewTokenService(cfg, log, repos) @@ -138,6 +138,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont } siteSvc := NewSiteService(log, repos, flareSolverrURL) downloads := NewDownloadService(log, repos, hub, organizer, siteSvc) + downloads.SetScanner(scanner) subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub) // 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等 diff --git a/internal/service/transfer.go b/internal/service/transfer.go index e492bd4..9f5ed77 100644 --- a/internal/service/transfer.go +++ b/internal/service/transfer.go @@ -63,8 +63,7 @@ func transferFile(src, dst string, mode TransferMode) error { return copyFile(src, dst) case TransferHardlink: if err := os.Link(src, dst); err != nil { - // 跨文件系统无法硬链接,退化为复制,仍保留源文件以便继续做种。 - return copyFile(src, dst) + return fmt.Errorf("hardlink failed: %w; source and target must be on the same filesystem, choose copy if you want to duplicate data", err) } return nil case TransferSymlink: diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 500e3a5..33549e6 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -11,6 +11,9 @@ $ErrorActionPreference = "Stop" $Root = Resolve-Path (Join-Path $PSScriptRoot "..") if (-not $DataDir) { $DataDir = Join-Path $Root "data" } if (-not $CacheDir) { $CacheDir = Join-Path $Root "cache" } +$PreviewRoot = Split-Path -Parent $DataDir +$DefaultDownloadDir = Join-Path $PreviewRoot "downloads" +$DefaultMediaDir = Join-Path $PreviewRoot "media" $WebDir = Join-Path $Root "web\dist" $BinDir = Join-Path $Root "bin" $Exe = Join-Path $BinDir "mediastation-go.exe" @@ -20,6 +23,12 @@ $OutLog = Join-Path $LogDir "mediastation.out.log" $ErrLog = Join-Path $LogDir "mediastation.err.log" New-Item -ItemType Directory -Force -Path $DataDir, $CacheDir, $BinDir, $LogDir | Out-Null +if (-not $env:MEDIASTATION_DOWNLOAD_CONTAINER_DIR) { + New-Item -ItemType Directory -Force -Path $DefaultDownloadDir | Out-Null +} +if (-not $env:MEDIASTATION_MEDIA_CONTAINER_DIR) { + New-Item -ItemType Directory -Force -Path $DefaultMediaDir | Out-Null +} if (-not $SkipBuild) { Write-Host "[1/3] Building frontend" @@ -56,6 +65,12 @@ $env:MEDIASTATION_APP_DATA_DIR = $DataDir $env:MEDIASTATION_APP_WEB_DIR = $WebDir $env:MEDIASTATION_DATABASE_DB_PATH = (Join-Path $DataDir "mediastation.db") $env:MEDIASTATION_CACHE_CACHE_DIR = $CacheDir +if (-not $env:MEDIASTATION_DOWNLOAD_CONTAINER_DIR) { + $env:MEDIASTATION_DOWNLOAD_CONTAINER_DIR = $DefaultDownloadDir +} +if (-not $env:MEDIASTATION_MEDIA_CONTAINER_DIR) { + $env:MEDIASTATION_MEDIA_CONTAINER_DIR = $DefaultMediaDir +} Write-Host "[3/3] Starting MediaStationGo on http://$HostAddress`:$Port" if ($Foreground) { diff --git a/web/src/App.tsx b/web/src/App.tsx index 33aed60..0f1e24a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -81,9 +81,6 @@ const AIAssistantPage = lazy(() => const StrmPage = lazy(() => import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })), ) -const ToolsPage = lazy(() => - import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })), -) const ProfileManagementPage = lazy(() => import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })), ) @@ -210,11 +207,7 @@ export default function App() { /> - - - } + element={} /> + list: (path = '', max = 1000, recursive = false) => api - .get('/files', { params: { path, max } }) + .get('/files', { params: { path, max, recursive } }) + .then((r) => r.data), + + createFolder: (path: string, name: string) => + api.post<{ path: string }>('/files/folders', { path, name }).then((r) => r.data), + + rename: (path: string, name: string) => + api.put<{ path: string }>('/files/rename', { path, name }).then((r) => r.data), + + remove: (path: string) => + api.delete<{ removed: boolean }>('/files', { params: { path } }).then((r) => r.data), + + transfer: (sourcePath: string, destPath: string, transferMode = 'copy') => + api + .post<{ path: string }>('/files/transfer', { + source_path: sourcePath, + dest_path: destPath, + transfer_mode: transferMode, + }) .then((r) => r.data), } diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 536dd22..fe863ca 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -10,6 +10,10 @@ export interface OrganizeOverrides { source_path?: string dest_path?: string transfer_mode?: string + media_type?: string + scan_after?: boolean + library_id?: string + dry_run?: boolean } // OrganizeSource is a selectable organize source directory (e.g. the download @@ -45,7 +49,34 @@ export const toolsAPI = { // into the destination with dedup + 洗版 (resolution replacement). organizeDirectory: (opts: OrganizeOverrides) => api - .post<{ organized: number; skipped: number; replaced?: number; errors?: string[] }>( + .post<{ + organized: number + skipped: number + replaced?: number + source_path?: string + dest_path?: string + errors?: string[] + dry_run?: boolean + items?: Array<{ + source: string + target?: string + action: string + reason?: string + media_type?: string + category?: string + title?: string + }> + scans?: Array<{ + library_id: string + name: string + path: string + visited: number + added: number + updated: number + removed: number + error?: string + }> + }>( '/admin/organize/source', opts, ) diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 414b92a..7d72b7a 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { Activity, Bell, Clock, CloudDownload, Compass, Film, Cast, Globe, HardDrive, Heart, Home, Image, KeySquare, ListMusic, LogOut, Rss, Search, - Settings, Sliders, Sparkles, UserCog, Wrench, + Settings, Sliders, Sparkles, UserCog, Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X } from 'lucide-react' import clsx from 'clsx' @@ -194,7 +194,6 @@ export function Layout() {
} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="授权许可" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index f0f5041..634f37f 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -46,7 +46,7 @@ export function AdminPage() { items={[ { to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' }, { to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' }, - { to: '/tools', title: '整理与维护', description: '媒体整理、任务维护和辅助工具' }, + { to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库' }, { to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' }, ]} /> diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index a1255a1..fc94efe 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -1,7 +1,28 @@ -import { useCallback, useEffect, useState } from 'react' -import { ChevronUp, FileVideo, Folder, FolderOpen, Home } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import toast from 'react-hot-toast' +import { + ChevronUp, + Copy, + FileVideo, + Folder, + FolderOpen, + GitBranch, + HardDrive, + Home, + Move, + Pencil, + Plus, + RefreshCw, + Trash2, +} from 'lucide-react' import { filesAPI, type FileEntry, type FileListing } from '../api/files' +import { adminAPI } from '../api/admin' +import { libraryAPI } from '../api/library' +import { schedulerAPI } from '../api/scheduler' +import { toolsAPI } from '../api/tools' +import { confirmAction } from '../components/ConfirmDialog' +import type { Library, Setting } from '../types' function fmtBytes(n: number): string { if (!n) return '0 B' @@ -15,19 +36,107 @@ function fmtBytes(n: number): string { return `${v.toFixed(1)} ${u[i]}` } -// FileManagerPage browses the server's filesystem within the allowed -// roots so the operator can pick library paths visually. +function formatScanSummary(scans: Array<{ name: string; added: number; updated: number; visited: number; error?: string }>): string { + if (scans.length === 0) return ' · 未扫描:没有匹配的媒体库' + const ok = scans.filter((scan) => !scan.error) + const added = ok.reduce((sum, scan) => sum + (scan.added ?? 0), 0) + const updated = ok.reduce((sum, scan) => sum + (scan.updated ?? 0), 0) + const visited = ok.reduce((sum, scan) => sum + (scan.visited ?? 0), 0) + return ` · 扫描 ${ok.length}/${scans.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}` +} + +type AutoOrganizeConfig = { + enabled: string + afterDownload: string + sourceDir: string + targetDir: string + transferMode: string + intervalSeconds: string +} + +const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { + enabled: 'false', + afterDownload: 'false', + sourceDir: '', + targetDir: '', + transferMode: 'hardlink', + intervalSeconds: '300', +} + +const AUTO_ORGANIZE_KEYS: Record = { + enabled: 'organize.auto', + afterDownload: 'organizer.auto_after_download', + sourceDir: 'organize.source_dir', + targetDir: 'organize.target_dir', + transferMode: 'organize.transfer_mode', + intervalSeconds: 'organize.interval_seconds', +} + +function settingIndex(rows: Setting[]): Record { + const out: Record = {} + for (const row of rows) out[row.key] = row.value + return out +} + +function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig { + const idx = settingIndex(rows) + return { + enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled, + afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload, + sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir, + targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir, + transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode, + intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds, + } +} + +function settingOn(value: string): boolean { + return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase()) +} + +// FileManagerPage mirrors MoviePilot's local Storage view at a smaller scope: +// browse allowed roots, optionally recurse, and perform safe local operations. export function FileManagerPage() { + const [libraries, setLibraries] = useState([]) const [path, setPath] = useState('') const [data, setData] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(true) + const [recursive, setRecursive] = useState(false) + const [selected, setSelected] = useState(null) + const [folderName, setFolderName] = useState('') + const [renameTo, setRenameTo] = useState('') + const [destPath, setDestPath] = useState('') + const [transferMode, setTransferMode] = useState('copy') + const [busy, setBusy] = useState('') + const [organizeLibraryID, setOrganizeLibraryID] = useState('') + const [organizeDestPath, setOrganizeDestPath] = useState('') + const [organizeTransferMode, setOrganizeTransferMode] = useState('hardlink') + const [organizeMediaType, setOrganizeMediaType] = useState('auto') + const [scanAfter, setScanAfter] = useState(true) + const [organizeBusy, setOrganizeBusy] = useState('') + const [previewItems, setPreviewItems] = useState>([]) + const [autoConfig, setAutoConfig] = useState(AUTO_ORGANIZE_DEFAULTS) + const [autoDirty, setAutoDirty] = useState(false) + const [autoSaving, setAutoSaving] = useState(false) + const [autoRunning, setAutoRunning] = useState(false) + const [autoLoading, setAutoLoading] = useState(true) + + const currentDir = useMemo(() => { + if (data?.path) return data.path + return path + }, [data?.path, path]) const refresh = useCallback(() => { setLoading(true) setError('') filesAPI - .list(path) + .list(path, recursive ? 5000 : 1000, recursive) .then(setData) .catch((err: unknown) => { const msg = @@ -36,41 +145,235 @@ export function FileManagerPage() { setError(msg) }) .finally(() => setLoading(false)) - }, [path]) + }, [path, recursive]) useEffect(() => { refresh() }, [refresh]) + useEffect(() => { + libraryAPI.list({ includeHidden: true }).then(setLibraries).catch(() => undefined) + }, []) + + const refreshAutoConfig = useCallback(() => { + setAutoLoading(true) + adminAPI + .listSettings() + .then((rows) => { + setAutoConfig(mergeAutoOrganizeSettings(rows)) + setAutoDirty(false) + }) + .catch(() => undefined) + .finally(() => setAutoLoading(false)) + }, []) + + useEffect(() => { + refreshAutoConfig() + }, [refreshAutoConfig]) + + useEffect(() => { + setSelected(null) + setRenameTo('') + }, [path]) + + useEffect(() => { + const lib = libraries.find((item) => item.id === organizeLibraryID) + if (!lib) return + setOrganizeDestPath(lib.path) + setOrganizeMediaType(lib.type || 'auto') + }, [libraries, organizeLibraryID]) + + const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => { + setAutoConfig((current) => ({ ...current, [key]: value })) + setAutoDirty(true) + } + + const saveAutoConfig = async (): Promise => { + setAutoSaving(true) + try { + for (const key of Object.keys(AUTO_ORGANIZE_KEYS) as Array) { + await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '') + } + setAutoDirty(false) + toast.success('自动整理设置已保存') + return true + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败') + return false + } finally { + setAutoSaving(false) + } + } + + const runAutoOrganizeNow = async () => { + if (autoDirty) { + const saved = await saveAutoConfig() + if (!saved) return + } + setAutoRunning(true) + try { + await schedulerAPI.run('organize_source') + toast.success('已触发自动整理任务,请稍后刷新媒体库查看入库结果') + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '触发自动整理失败') + } finally { + setAutoRunning(false) + } + } + const enter = (e: FileEntry) => { if (e.is_dir) setPath(e.path) } + const choose = (e: FileEntry) => { + setSelected(e) + setRenameTo(e.name) + if (e.is_dir) setDestPath(e.path) + } + + const createFolder = async () => { + if (!currentDir || !folderName.trim()) return + setBusy('mkdir') + try { + const res = await filesAPI.createFolder(currentDir, folderName.trim()) + toast.success(`已创建目录:${res.path}`) + setFolderName('') + refresh() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败') + } finally { + setBusy('') + } + } + + const renameSelected = async () => { + if (!selected || !renameTo.trim()) return + setBusy('rename') + try { + const res = await filesAPI.rename(selected.path, renameTo.trim()) + toast.success(`已重命名:${res.path}`) + setSelected(null) + refresh() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重命名失败') + } finally { + setBusy('') + } + } + + const deleteSelected = async () => { + if (!selected) return + const ok = await confirmAction({ + title: '确认删除文件', + message: `将删除:${selected.path}。此操作不可恢复,请确认不是媒体库根目录。`, + confirmText: '确认删除', + danger: true, + }) + if (!ok) return + setBusy('delete') + try { + await filesAPI.remove(selected.path) + toast.success('已删除') + setSelected(null) + refresh() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败') + } finally { + setBusy('') + } + } + + const transferSelected = async () => { + if (!selected || selected.is_dir || !destPath.trim()) return + setBusy('transfer') + try { + const res = await filesAPI.transfer(selected.path, destPath.trim(), transferMode) + toast.success(`已完成转移:${res.path}`) + if (transferMode === 'move') setSelected(null) + refresh() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转移失败') + } finally { + setBusy('') + } + } + + const organizeSource = selected?.path || currentDir + const organizeReady = Boolean(organizeSource && organizeDestPath.trim()) + + const runManualOrganize = async (dryRun: boolean) => { + if (!organizeReady) { + toast.error('请选择来源文件/文件夹,并设置目标媒体库或目的路径') + return + } + if (!dryRun) { + const ok = await confirmAction({ + title: '确认整理入库', + message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}`, + confirmText: '开始整理', + }) + if (!ok) return + } + setOrganizeBusy(dryRun ? 'preview' : 'run') + try { + const result = await toolsAPI.organizeDirectory({ + source_path: organizeSource, + dest_path: organizeDestPath.trim(), + transfer_mode: organizeTransferMode, + media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType, + scan_after: !dryRun && scanAfter, + library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined, + dry_run: dryRun, + }) + setPreviewItems(result.items ?? []) + const replaced = result.replaced ?? 0 + const total = (result.organized ?? 0) + replaced + (result.skipped ?? 0) + (result.errors?.length ?? 0) + if (total === 0) { + toast(`未发现可整理视频:${result.source_path || organizeSource}`, { + icon: '⚠️', + duration: 6000, + }) + return + } + if (dryRun) { + toast.success(`预览完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}`) + return + } + const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : '' + toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}`) + refresh() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败') + } finally { + setOrganizeBusy('') + } + } + return (
-

文件浏览器

+

文件管理

- 只允许访问已配置的根目录(媒体库 + data + cache)。 + 像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。

-
- {data?.parent && ( - )} + + {data?.path && ( {data.path} @@ -78,17 +381,302 @@ export function FileManagerPage() { )}
+
+
+
+

自动整理设置

+

+ 设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。 +

+
+
+ + + +
+
+ +
+ + + + +
+ +
+ + + + {autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source + +
+
+ + {data?.path && ( +
+
+
+

手动整理入库

+

来源优先使用选中项;未选中时使用当前目录。

+
+
+ 来源:{organizeSource || '未选择'} +
+
+ +
+ + + + +
+ +
+ + + +
+ + {previewItems.length > 0 && ( +
+ + + + + + + + + + + {previewItems.map((item, index) => ( + + + + + + + ))} + +
动作来源目标原因
{item.action}{item.source}{item.target || '—'}{item.reason || '—'}
+
+ )} +
+ )} + + {data?.path && ( +
+ + 文件操作 + + 新建目录 / 重命名 / 删除 / 转移 + + +
+
+

新建目录

+
+ setFolderName(e.target.value)} /> + +
+
+ +
+

选中项

+ {selected ? ( +
+

{selected.path}

+
+ setRenameTo(e.target.value)} /> + + +
+ {!selected.is_dir && ( +
+ setDestPath(e.target.value)} /> + + +
+ )} +
+ ) : ( +

先在下方列表点击“操作”选择文件或目录。

+ )} +
+
+
+ )} + {loading &&

加载中…

} {error &&
{error}
} - {!loading && data && !data.entries && data.roots && ( + {!loading && data && !data.path && data.roots && (
{data.roots.map((r) => ( -