mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
fix: improve media file organization workflow
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "动画"):
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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: '查看占用、清理重复项和管理文件' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">整理 & 重命名</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" />
|
||||
整理 & 重命名 默认设置(与设置页同步,改这里即可,无需再去设置页)
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user