fix: improve media file organization workflow

This commit is contained in:
ShukeBta
2026-06-09 13:57:34 +08:00
parent 76d6e0c8ff
commit fe0e7a09ce
28 changed files with 2054 additions and 859 deletions
+80 -10
View File
@@ -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)
}
+17 -2
View File
@@ -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)
}
}
+4
View File
@@ -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))
+6
View File
@@ -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"},
+60 -13
View File
@@ -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 ""
}
+42
View File
@@ -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 != "测试影片" {
+263 -87
View File
@@ -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
}
}
+150
View File
@@ -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)
}
}
}
+4
View File
@@ -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, "动画"):
+25 -7
View File
@@ -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 ""
}
+131 -9
View File
@@ -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
}
+247 -6
View File
@@ -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)
}
}
+91
View File
@@ -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
}
+5
View File
@@ -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.
+70 -5
View File
@@ -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.
+107
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
+1 -2
View File
@@ -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:
+15
View File
@@ -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) {
+1 -8
View File
@@ -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() {
/>
<Route
path="tools"
element={
<RequireAdmin>
<ToolsPage />
</RequireAdmin>
}
element={<Navigate to="/storage" replace />}
/>
<Route
path="notify-channels"
+21 -2
View File
@@ -6,6 +6,7 @@ export interface FileEntry {
is_dir: boolean
size: number
modified: number
ext?: string
}
export interface FileListing {
@@ -16,8 +17,26 @@ export interface FileListing {
}
export const filesAPI = {
list: (path = '', max = 1000) =>
list: (path = '', max = 1000, recursive = false) =>
api
.get<FileListing>('/files', { params: { path, max } })
.get<FileListing>('/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),
}
+32 -1
View File
@@ -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,
)
+1 -2
View File
@@ -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() {
<div className="space-y-1">
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="授权许可" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
+1 -1
View File
@@ -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: '查看占用、清理重复项和管理文件' },
]}
/>
+628 -45
View File
@@ -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<keyof AutoOrganizeConfig, string> = {
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<string, string> {
const out: Record<string, string> = {}
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<Library[]>([])
const [path, setPath] = useState('')
const [data, setData] = useState<FileListing | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [recursive, setRecursive] = useState(false)
const [selected, setSelected] = useState<FileEntry | null>(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<Array<{
source: string
target?: string
action: string
reason?: string
}>>([])
const [autoConfig, setAutoConfig] = useState<AutoOrganizeConfig>(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<boolean> => {
setAutoSaving(true)
try {
for (const key of Object.keys(AUTO_ORGANIZE_KEYS) as Array<keyof AutoOrganizeConfig>) {
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 (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">文件浏览器</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
<p className="text-sm text-ink-50">
只允许访问已配置的根目录(媒体库 + data + cache)。
像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。
</p>
</header>
<div className="flex flex-wrap gap-2">
<button
className="neon-button !px-3 !py-1 !text-xs"
onClick={() => setPath('')}
title="返回根列表"
>
<div className="flex flex-wrap items-center gap-2">
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => setPath('')} title="返回根列表">
<Home size={14} /> 根
</button>
{data?.parent && (
<button
className="neon-button !px-3 !py-1 !text-xs"
onClick={() => setPath(data.parent ?? '')}
>
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => setPath(data.parent ?? '')}>
<ChevronUp size={14} /> 上一级
</button>
)}
<button className="neon-button !px-3 !py-1 !text-xs" onClick={refresh}>
<RefreshCw size={14} /> 刷新
</button>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={recursive} onChange={(e) => setRecursive(e.target.checked)} />
递归扫描
</label>
{data?.path && (
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 font-mono text-xs text-ink-100">
{data.path}
@@ -78,17 +381,302 @@ export function FileManagerPage() {
)}
</div>
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">自动整理设置</h2>
<p className="text-xs text-sand-500">
设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={autoLoading || autoSaving}
onClick={refreshAutoConfig}
>
<RefreshCw size={14} /> 重新读取
</button>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={autoLoading || autoSaving || !autoDirty}
onClick={() => void saveAutoConfig()}
>
{autoSaving ? '保存中…' : '保存设置'}
</button>
<button
type="button"
className="neon-button"
disabled={autoLoading || autoSaving || autoRunning}
onClick={runAutoOrganizeNow}
>
{autoRunning ? '执行中…' : '立即整理一次'}
</button>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={autoConfig.sourceDir}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<span className="text-xs text-sand-500">
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
</span>
</div>
</section>
{data?.path && (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">手动整理入库</h2>
<p className="text-xs text-sand-500">来源优先使用选中项;未选中时使用当前目录。</p>
</div>
<div className="max-w-xl truncate rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-ink-100" title={organizeSource}>
来源:{organizeSource || '未选择'}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">目标媒体库 / 存储</span>
<select
className="input-base w-full"
value={organizeLibraryID}
onChange={(event) => setOrganizeLibraryID(event.target.value)}
>
<option value="">手动填写目的路径</option>
{libraries.map((library) => (
<option key={library.id} value={library.id}>
{library.name}({library.type})— {library.path}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">目的路径</span>
<input
className="input-base w-full"
placeholder="例如 F:\\media\\电影 或 /media/电影"
value={organizeDestPath}
onChange={(event) => setOrganizeDestPath(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">类型</span>
<select className="input-base w-full" value={organizeMediaType} onChange={(event) => setOrganizeMediaType(event.target.value)}>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理方式</span>
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => setOrganizeTransferMode(event.target.value)}>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={scanAfter} onChange={(event) => setScanAfter(event.target.checked)} />
整理后扫描入库
</label>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={!organizeReady || organizeBusy !== ''}
onClick={() => runManualOrganize(true)}
>
{organizeBusy === 'preview' ? '预览中…' : '预览整理'}
</button>
<button
type="button"
className="neon-button"
disabled={!organizeReady || organizeBusy !== ''}
onClick={() => runManualOrganize(false)}
>
{organizeBusy === 'run' ? '整理中…' : '开始整理入库'}
</button>
</div>
{previewItems.length > 0 && (
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-white text-sand-500">
<tr>
<th className="px-3 py-2">动作</th>
<th>来源</th>
<th>目标</th>
<th>原因</th>
</tr>
</thead>
<tbody>
{previewItems.map((item, index) => (
<tr key={`${item.source}-${index}`} className="border-t border-gray-200 align-top">
<td className="px-3 py-2 font-semibold text-brand-500">{item.action}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.source}>{item.source}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.target}>{item.target || '—'}</td>
<td className="py-2 text-sand-500">{item.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{data?.path && (
<details className="glass-panel" open={Boolean(selected)}>
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
文件操作
<span className="ml-2 text-xs font-normal text-sand-500">
新建目录 / 重命名 / 删除 / 转移
</span>
</summary>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">新建目录</h2>
<div className="flex gap-2">
<input className="input-base flex-1" placeholder="新目录名称" value={folderName} onChange={(e) => setFolderName(e.target.value)} />
<button className="neon-button" disabled={busy === 'mkdir' || !folderName.trim()} onClick={createFolder}>
<Plus size={16} /> 创建
</button>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">选中项</h2>
{selected ? (
<div className="space-y-2">
<p className="truncate font-mono text-xs text-ink-50" title={selected.path}>{selected.path}</p>
<div className="flex flex-wrap gap-2">
<input className="input-base min-w-[220px] flex-1" value={renameTo} onChange={(e) => setRenameTo(e.target.value)} />
<button className="neon-button" disabled={busy === 'rename' || !renameTo.trim()} onClick={renameSelected}>
<Pencil size={16} /> 重命名
</button>
<button className="rounded-xl border border-red-400/40 px-3 py-2 text-sm text-red-500 hover:bg-red-50" disabled={busy === 'delete'} onClick={deleteSelected}>
<Trash2 size={16} className="inline" /> 删除
</button>
</div>
{!selected.is_dir && (
<div className="grid gap-2 md:grid-cols-[1fr_140px_auto]">
<input className="input-base" placeholder="目标目录路径" value={destPath} onChange={(e) => setDestPath(e.target.value)} />
<select className="input-base" value={transferMode} onChange={(e) => setTransferMode(e.target.value)}>
<option value="copy">复制</option>
<option value="move">移动</option>
<option value="hardlink">硬链接</option>
<option value="symlink">软链接</option>
</select>
<button className="neon-button" disabled={busy === 'transfer' || !destPath.trim()} onClick={transferSelected}>
{transferMode === 'move' ? <Move size={16} /> : transferMode === 'copy' ? <Copy size={16} /> : <GitBranch size={16} />}
转移
</button>
</div>
)}
</div>
) : (
<p className="text-sm text-ink-50">先在下方列表点击“操作”选择文件或目录。</p>
)}
</div>
</div>
</details>
)}
{loading && <p className="text-sand-500">加载中…</p>}
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
{!loading && data && !data.entries && data.roots && (
{!loading && data && !data.path && data.roots && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.roots.map((r) => (
<button
key={r.path}
onClick={() => setPath(r.path)}
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
>
<button key={r.path} onClick={() => setPath(r.path)} className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40">
<FolderOpen size={20} className="text-brand-500" />
<div>
<p className="font-mono text-sm text-ink-600">{r.label}</p>
@@ -100,34 +688,31 @@ export function FileManagerPage() {
)}
{!loading && data?.entries && data.entries.length > 0 && (
<div className="glass-panel">
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>大小</th>
<th>修改时间</th>
<th className="text-right">选择</th>
</tr>
</thead>
<tbody>
{data.entries.map((e) => (
<tr
key={e.path}
className="cursor-pointer border-t border-gray-200 transition hover:bg-gray-50"
onClick={() => enter(e)}
title={e.path}
>
<td className="flex items-center gap-2 py-2 text-ink-600">
{e.is_dir ? (
<Folder size={16} className="text-brand-500" />
) : (
<FileVideo size={16} className="text-ink-50" />
)}
{e.name}
{data.entries.map((entry) => (
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path ? 'bg-primary-400/5' : '')}>
<td className="py-2 text-ink-600">
<button className="flex max-w-xl items-center gap-2 text-left" onClick={() => (entry.is_dir ? enter(entry) : choose(entry))} title={entry.path}>
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
<span className="truncate">{recursive ? entry.path.replace((data.path || '') + '\\', '').replace((data.path || '') + '/', '') : entry.name}</span>
</button>
</td>
<td className="text-ink-100">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
<td className="text-sand-500">
{new Date(e.modified * 1000).toLocaleString()}
<td className="text-ink-100">{entry.is_dir ? '—' : fmtBytes(entry.size)}</td>
<td className="text-sand-500">{new Date(entry.modified * 1000).toLocaleString()}</td>
<td className="text-right">
<button className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40" onClick={() => choose(entry)}>
<HardDrive size={12} className="mr-1 inline" /> 操作
</button>
</td>
</tr>
))}
@@ -136,9 +721,7 @@ export function FileManagerPage() {
</div>
)}
{!loading && data?.entries && data.entries.length === 0 && (
<p className="text-ink-50">空目录。</p>
)}
{!loading && data?.entries && data.entries.length === 0 && <p className="text-ink-50">空目录。</p>}
</div>
)
}
+19 -3
View File
@@ -148,9 +148,17 @@ const GROUPS: SettingGroup[] = [
description: '媒体文件命名 + 自动刮削 + 整理目标',
items: [
{
key: 'organizer.auto_after_download',
label: '入库时自动整理',
key: 'organize.auto',
label: '整理源目录定时自动整理',
type: 'toggle',
hint: '开启后后台会按下方间隔递归扫描「整理源目录」,自动整理到「整理目的地目录」并扫描入库。默认关闭,避免无意中频繁读盘。',
defaultValue: 'false',
},
{
key: 'organizer.auto_after_download',
label: '下载完成后自动整理入库',
type: 'toggle',
hint: '开启后 qB 下载完成时,系统会优先使用种子的 content_path 整理该文件/目录,并在整理完成后扫描目标媒体库。',
},
{
key: 'downloads.smart_classify',
@@ -191,11 +199,19 @@ const GROUPS: SettingGroup[] = [
{ value: 'symlink', label: '软链接(符号链接,保留源)' },
],
},
{
key: 'organize.interval_seconds',
label: '自动整理间隔秒数',
type: 'number',
hint: '仅在「整理源目录定时自动整理」开启后生效;最小 60 秒,建议 300 秒或更高。',
defaultValue: '300',
placeholder: '300',
},
{
key: 'organize.keep_seeding',
label: '保种(整理后继续做种上传)',
type: 'toggle',
hint: '开启后即使选择「移动」也会自动改用硬链接(跨盘退化为复制)保留源文件,确保 qBittorrent 转移后继续做种上传。',
hint: '开启后即使选择「移动」也会自动改用硬链接保留源文件,确保 qBittorrent 继续做种。硬链接要求源和目标在同一文件系统;失败时会提示,不再静默复制占用双倍空间。',
},
{
key: 'organize.movie_format',
+31 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Database, HardDrive, PieChart } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
@@ -44,24 +45,37 @@ export function StoragePage() {
<header className="flex items-center gap-3">
<HardDrive className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">存储</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">存储与文件</h1>
<p className="text-sm text-ink-50">
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
文件浏览、整理入库、排重与存储统计统一在这里,避免入口重复。
</p>
</div>
</header>
<ManagementShortcuts
title="存储与文件入口"
description="存储统计只负责看数据,文件管理、排重、回收与配置入口集中放在这里。"
title="核心操作"
description="保留常用入口:先整理/入库,再按需进入文件或清理功能。"
items={[
{ to: '/files', title: '文件管理', description: '浏览服务器文件并执行基础文件操作' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作' },
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
]}
/>
<details className="glass-panel group">
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
</summary>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-5">
<MaintenanceLink to="/strm" title="STRM 生成" />
<MaintenanceLink to="/scheduler" title="定时任务" />
<MaintenanceLink to="/tasks" title="任务队列" />
<MaintenanceLink to="/notify-channels" title="通知渠道" />
<MaintenanceLink to="/assistant" title="AI 对话台" />
</div>
</details>
<section className="grid gap-4 sm:grid-cols-3">
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
@@ -151,3 +165,14 @@ function Tile({
</div>
)
}
function MaintenanceLink({ to, title }: { to: string; title: string }) {
return (
<Link
to={to}
className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-ink-100 transition hover:border-primary-400/40 hover:text-brand-500"
>
{title}
</Link>
)
}
-649
View File
@@ -1,649 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, FolderCog, Info, Loader2, Search, Wrench } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI, type OrganizeSource } from '../api/tools'
import type { Library, Media, Setting } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
// ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own:
//
// - Organize one media item or an entire library: renames + moves files
// into the canonical layout (`Library/Year/Title (Year)/...`).
// - Send a test notification through every configured channel.
//
// The original Vue version surfaces these as tabs inside SettingsView; here
// we co-locate them on a single admin tools page.
export function ToolsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-400/10 text-violet-400">
<Wrench size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">运维工具</h1>
<p className="text-sm text-ink-50">整理媒体文件 · 测试通知渠道</p>
</div>
</div>
<ManagementShortcuts
title="运维与自动化入口"
description="把整理、任务、通知和高级辅助功能统一放回工具台。"
items={[
{ to: '/strm', title: 'STRM 生成', description: '生成 STRM 文件供外部播放器或媒体服务使用' },
{ to: '/scheduler', title: '定时任务', description: '查看和维护自动扫描、刮削与订阅任务' },
{ to: '/tasks', title: '任务队列', description: '跟踪后台任务执行状态和失败原因' },
{ to: '/notify-channels', title: '通知渠道', description: '配置并测试消息通知渠道' },
{ to: '/assistant', title: 'AI 对话台', description: '进入管理员辅助诊断和问答界面' },
]}
/>
<OrganizePanel />
<NotifyPanel />
</div>
)
}
function isOn(v: string | undefined): boolean {
return v === 'true' || v === '1' || v === 'on'
}
function OrganizePanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryID, setLibraryID] = useState('')
const [running, setRunning] = useState(false)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<Media[]>([])
const [busyID, setBusyID] = useState<string | null>(null)
const [smartClassify, setSmartClassify] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(true)
// 整理 & 重命名「默认设置」内联编辑器:与设置页共用同一批 organize.* 配置键,
// 这样管理员无需在「设置」和「工具」之间来回跳转即可调默认值并直接执行整理。
const [showDefaults, setShowDefaults] = useState(false)
const [savingDefaults, setSavingDefaults] = useState(false)
const ORGANIZE_KEYS = [
'organizer.auto_after_download',
'downloads.smart_classify',
'organizer.smart_classify',
'organize.source_dir',
'organize.target_dir',
'organize.transfer_mode',
'organize.keep_seeding',
'organize.movie_format',
'organize.tv_format',
'organize.anime_format',
] as const
const [defaults, setDefaults] = useState<Record<string, string>>({})
const setDefault = (key: string, value: string) =>
setDefaults((prev) => ({ ...prev, [key]: value }))
// 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。
const [sourcePath, setSourcePath] = useState('')
const [destPath, setDestPath] = useState('')
const [transferMode, setTransferMode] = useState('')
// 整理来源目录(如下载目录):可选择整个目录作为整理源,不要求是已登记媒体库。
const [sources, setSources] = useState<OrganizeSource[]>([])
const [sourceDir, setSourceDir] = useState('')
const [organizingDir, setOrganizingDir] = useState(false)
const [ingestingDir, setIngestingDir] = useState(false)
const overrides = () => {
const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {}
if (sourcePath.trim()) o.source_path = sourcePath.trim()
if (destPath.trim()) o.dest_path = destPath.trim()
if (transferMode) o.transfer_mode = transferMode
return o
}
useEffect(() => {
libraryAPI.list({ includeHidden: true }).then(setLibraries).catch(() => undefined)
toolsAPI
.organizeSources()
.then((s) => {
setSources(s)
if (s.length > 0) setSourceDir(s[0].path)
})
.catch(() => undefined)
}, [])
useEffect(() => {
const loadSettings = async () => {
try {
const settings = (await adminAPI.listSettings()) as Setting[]
const byKey: Record<string, string> = {}
for (const s of settings) byKey[s.key] = s.value
const next: Record<string, string> = {}
for (const k of ORGANIZE_KEYS) next[k] = byKey[k] ?? ''
setDefaults(next)
const sc = byKey['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
} catch {
// ignore
} finally {
setLoadingSettings(false)
}
}
loadSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSaveDefaults = async (e: FormEvent) => {
e.preventDefault()
setSavingDefaults(true)
try {
for (const k of ORGANIZE_KEYS) {
await adminAPI.updateSetting(k, defaults[k] ?? '')
}
const sc = defaults['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
toast.success('整理默认设置已保存')
} catch {
toast.error('保存失败')
} finally {
setSavingDefaults(false)
}
}
const onOrganizeLibrary = async (e: FormEvent) => {
e.preventDefault()
if (!libraryID) return
setRunning(true)
try {
await toolsAPI.organizeLibrary(libraryID, overrides())
toast.success('已触发媒体库整理')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setRunning(false)
}
}
const runOrganizeDir = async (scanAfter: boolean) => {
const src = (sourceDir || sourcePath).trim()
if (!src) {
toast.error('请选择或填写整理来源目录')
return
}
const ok = await confirmAction({
title: scanAfter ? '确认整理并入库' : '确认整理来源目录',
message: scanAfter
? '将从来源目录整理/重命名媒体文件到目的地目录,然后扫描媒体库把新文件入库。若开启洗版,高分辨率来源可能会替换低分辨率旧版本。'
: '将从来源目录整理/重命名媒体文件到目的地目录,并执行去重与洗版判断。此操作不会自动扫描入库。',
confirmText: scanAfter ? '开始整理入库' : '确认整理',
danger: false,
})
if (!ok) return
if (scanAfter) setIngestingDir(true)
else setOrganizingDir(true)
try {
const r = await toolsAPI.organizeDirectory({
source_path: src,
dest_path: destPath.trim() || undefined,
transfer_mode: transferMode || undefined,
})
const replaced = r.replaced ?? 0
let scanSummary = ''
if (scanAfter) {
scanSummary = await scanLibrariesForIngest()
}
toast.success(
`整理完成:新增 ${r.organized} · 替换(洗版) ${replaced} · 去重跳过 ${r.skipped}${scanSummary}`,
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setOrganizingDir(false)
setIngestingDir(false)
}
}
const onOrganizeDir = async (e: FormEvent) => {
e.preventDefault()
await runOrganizeDir(false)
}
const scanLibrariesForIngest = async (): Promise<string> => {
let availableLibraries = libraries
if (availableLibraries.length === 0) {
availableLibraries = await libraryAPI.list({ includeHidden: true }).catch(() => [])
if (availableLibraries.length > 0) setLibraries(availableLibraries)
}
const targets = (libraryID
? availableLibraries.filter((l) => l.id === libraryID)
: availableLibraries.filter((l) => l.enabled !== false)
)
if (targets.length === 0) return ' · 未找到可扫描媒体库'
let added = 0
let updated = 0
let visited = 0
const failed: string[] = []
for (const lib of targets) {
try {
const res = await libraryAPI.scan(lib.id)
added += res.added ?? 0
updated += res.updated ?? 0
visited += res.visited ?? 0
} catch {
failed.push(lib.name)
}
}
if (failed.length > 0) {
toast.error(`部分媒体库扫描失败:${failed.join('、')}`)
}
return ` · 入库扫描 ${targets.length - failed.length}/${targets.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}`
}
const doSearch = async (e?: FormEvent) => {
e?.preventDefault()
if (!query.trim()) return
setSearching(true)
try {
const r = await mediaAPI.search(query.trim(), 20)
setResults(r.items ?? [])
} catch {
toast.error('搜索失败')
} finally {
setSearching(false)
}
}
const onOrganizeOne = async (m: Media) => {
setBusyID(m.id)
try {
const r = await toolsAPI.organizeMedia(m.id, overrides())
toast.success(`已整理到 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setBusyID(null)
}
}
return (
<section className="glass-panel space-y-5">
<div className="flex items-center gap-2">
<FolderCog size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</h2>
</div>
<p className="text-xs text-sand-500">
将媒体文件按 <code className="rounded-lg bg-gray-50 px-1">媒体库/电影或电视剧/分类/标题/Season</code>{' '}
的规范布局移动并重命名,确保已先完成刮削。
</p>
{!loadingSettings && (
<div className="flex items-center gap-2 text-xs">
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-sand-500'} />
<span className={smartClassify ? 'text-green-400' : 'text-sand-500'}>
智能分类:{smartClassify ? '已启用' : '未启用'}
</span>
{smartClassify && (
<span className="text-sand-500">(将根据元数据自动分类到子目录)</span>
)}
</div>
)}
<div className="rounded-xl border border-gray-200 bg-white/40">
<button
type="button"
onClick={() => setShowDefaults((v) => !v)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-medium text-ink-600"
>
<span className="flex items-center gap-2">
<Wrench size={14} className="text-brand-500" />
整理 &amp; 重命名 默认设置(与设置页同步,改这里即可,无需再去设置页)
</span>
<span className="text-xs text-sand-500">{showDefaults ? '收起 ▲' : '展开 ▼'}</span>
</button>
{showDefaults && (
<form onSubmit={onSaveDefaults} className="space-y-3 border-t border-gray-200 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理源目录(待整理)</span>
<input
className="input-base w-full"
placeholder="留空则默认整理整个媒体库"
value={defaults['organize.source_dir'] ?? ''}
onChange={(e) => setDefault('organize.source_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理目的地目录</span>
<input
className="input-base w-full"
placeholder="留空则按媒体库归类"
value={defaults['organize.target_dir'] ?? ''}
onChange={(e) => setDefault('organize.target_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认转移方式</span>
<select
className="input-base w-full"
value={defaults['organize.transfer_mode'] ?? ''}
onChange={(e) => setDefault('organize.transfer_mode', e.target.value)}
>
<option value="">未设置</option>
<option value="move">移动(删除源文件)</option>
<option value="copy">复制(保留源文件)</option>
<option value="hardlink">硬链接(保留源,做种不中断)</option>
<option value="symlink">软链接(符号链接,保留源)</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">电影命名格式</span>
<input
className="input-base w-full"
placeholder="{title} ({year})"
value={defaults['organize.movie_format'] ?? ''}
onChange={(e) => setDefault('organize.movie_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">剧集命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - S{season:02d}E{episode:02d}"
value={defaults['organize.tv_format'] ?? ''}
onChange={(e) => setDefault('organize.tv_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">动漫命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - {episode:02d}"
value={defaults['organize.anime_format'] ?? ''}
onChange={(e) => setDefault('organize.anime_format', e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.auto_after_download'])}
onChange={(e) => setDefault('organizer.auto_after_download', e.target.checked ? 'true' : 'false')}
/>
入库时自动整理
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={!defaults['downloads.smart_classify'] || isOn(defaults['downloads.smart_classify'])}
onChange={(e) => setDefault('downloads.smart_classify', e.target.checked ? 'true' : 'false')}
/>
下载器智能分类
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.smart_classify'])}
onChange={(e) => setDefault('organizer.smart_classify', e.target.checked ? 'true' : 'false')}
/>
智能分类(按元数据分目录)
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organize.keep_seeding'])}
onChange={(e) => setDefault('organize.keep_seeding', e.target.checked ? 'true' : 'false')}
/>
保种(整理后继续做种)
</label>
<button type="submit" disabled={savingDefaults} className="neon-button ml-auto">
{savingDefaults ? <Loader2 size={16} className="animate-spin" /> : null}
保存默认设置
</button>
</div>
</form>
)}
</div>
<p className="text-xs text-sand-500">
整理是「<b>从源目录整理到目的地目录</b>」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用上方默认设置(默认源目录 = 媒体库路径)。
</p>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-ink-50">源目录(待整理,可选)</span>
<input
className="input-base w-full"
placeholder="留空则使用「整理源目录」设置或媒体库路径"
value={sourcePath}
onChange={(e) => setSourcePath(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">目的地目录(整理输出,可选)</span>
<input
className="input-base w-full"
placeholder="留空则使用「整理目的地目录」设置或媒体库路径"
value={destPath}
onChange={(e) => setDestPath(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">转移方式(可选,覆盖默认设置)</span>
<select
className="input-base w-full"
value={transferMode}
onChange={(e) => setTransferMode(e.target.value)}
>
<option value="">使用默认设置</option>
<option value="move">移动(删除源文件)</option>
<option value="copy">复制(保留源文件)</option>
<option value="hardlink">硬链接(保留源,做种不中断)</option>
<option value="symlink">软链接(符号链接,保留源)</option>
</select>
</label>
</div>
<div className="space-y-2 rounded-xl border border-brand-200 bg-brand-50/40 p-3">
<p className="text-xs text-ink-50">
<b>整理来源目录</b>:直接整理整个目录(如下载目录 <code className="rounded bg-gray-100 px-1">/downloads</code>),无需是已登记的媒体库。已存在于目的地的媒体会<b>自动去重跳过</b>,来源<b>分辨率更高</b>时会<b>替换(洗版)</b>旧版本。点击「开始整理入库」会在整理完成后自动扫描媒体库。
</p>
<form onSubmit={onOrganizeDir} className="flex flex-wrap gap-2">
<select
className="input-base flex-1 min-w-[220px]"
value={sourceDir}
onChange={(e) => setSourceDir(e.target.value)}
>
{sources.length === 0 && (
<option value="">(无可选来源目录,请在上方「源目录」手动填写)</option>
)}
{sources.map((s) => (
<option key={s.path} value={s.path}>
{s.label}({s.path})
</option>
))}
</select>
<button
type="submit"
disabled={organizingDir || ingestingDir || !(sourceDir || sourcePath).trim()}
className="neon-button !border-sand-400/40 !text-ink-100"
>
{organizingDir ? (
<Loader2 size={16} className="animate-spin" />
) : (
<FolderCog size={16} />
)}
确认整理(仅重命名/洗版)
</button>
<button
type="button"
disabled={organizingDir || ingestingDir || !(sourceDir || sourcePath).trim()}
onClick={() => runOrganizeDir(true)}
className="neon-button"
>
{ingestingDir ? (
<Loader2 size={16} className="animate-spin" />
) : (
<FolderCog size={16} />
)}
开始整理入库
</button>
</form>
<p className="text-[11px] text-sand-500">
入库扫描范围:如果下方已选择媒体库,则只扫描该库;未选择时扫描全部已启用媒体库。
</p>
</div>
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
<select
required
className="input-base flex-1 min-w-[200px]"
value={libraryID}
onChange={(e) => setLibraryID(e.target.value)}
>
<option value="" disabled>
选择要整理的媒体库
</option>
{libraries.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.type})
</option>
))}
</select>
<button type="submit" disabled={running || !libraryID} className="neon-button">
{running ? <Loader2 size={16} className="animate-spin" /> : <FolderCog size={16} />}
整理整个库
</button>
</form>
<div className="border-t border-gray-200 pt-4">
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
<input
className="input-base flex-1"
placeholder="或搜索单个媒体进行整理…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
</button>
</form>
{results.length > 0 && (
<ul className="mt-4 space-y-2">
{results.map((m) => (
<li
key={m.id}
className="flex items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm text-ink-600">{m.title}</div>
<div className="truncate text-xs text-ink-50">{m.path}</div>
</div>
<button
onClick={() => onOrganizeOne(m)}
disabled={busyID === m.id}
className="neon-button !px-3 !py-1 text-xs"
>
{busyID === m.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FolderCog size={12} />
)}
整理
</button>
</li>
))}
</ul>
)}
{!searching && query && results.length === 0 && (
<p className="mt-3 text-sm text-ink-50">未找到匹配的媒体。</p>
)}
</div>
</section>
)
}
function NotifyPanel() {
const [title, setTitle] = useState('MediaStation 测试通知')
const [body, setBody] = useState('如果你收到这条消息,说明通知渠道工作正常。')
const [sending, setSending] = useState(false)
const onSend = async (e: FormEvent) => {
e.preventDefault()
if (!title.trim() || !body.trim()) return
setSending(true)
try {
await toolsAPI.notifyTest(title.trim(), body.trim())
toast.success('已派发到所有已启用通道')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'发送失败'
toast.error(msg)
} finally {
setSending(false)
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Bell size={18} className="text-amber-300" />
<h2 className="font-display text-lg font-semibold text-ink-600">通知渠道测试</h2>
</div>
<p className="text-xs text-sand-500">
会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。
</p>
<form onSubmit={onSend} className="space-y-3">
<Field label="标题">
<input
required
className="input-base"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<Field label="内容">
<textarea
required
rows={3}
className="input-base resize-none"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</Field>
<button type="submit" disabled={sending} className="neon-button">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Bell size={16} />}
发送测试通知
</button>
</form>
</section>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}