mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +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:
|
||||
|
||||
Reference in New Issue
Block a user