mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
feat(organize): organize arbitrary source dir (e.g. downloads) with dedup + 洗版
- Add OrganizeDirectory service: walk an arbitrary source directory (download dir / NAS direct-read path) and organize video files into the destination, without requiring the source to be a registered library. - Dedup: skip media already present in the destination (matched by scanned DB identity title+year[/season+episode], robust to dir case/layout, plus a filesystem folder fallback). - 洗版 (resolution replacement): when the source resolution is higher than the existing version, replace the lower-res file (+NFO sidecar +DB row). Prefers scanned dimensions, then ffprobe, then filename token; never replaces on unknown resolution. - New endpoints: GET /admin/organize/sources (download/media dir candidates) and POST /admin/organize/source. - UI: ToolsPage adds a '整理来源目录(去重+洗版)' form so operators can pick the download dir as the organize source. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -347,6 +347,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
// File organizer.
|
||||
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
||||
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
||||
admin.GET("/organize/sources", organizeSourcesHandler(svc))
|
||||
admin.POST("/organize/source", organizeDirectoryHandler(svc))
|
||||
|
||||
// API key management (encrypted at rest).
|
||||
admin.GET("/api-configs", listAPIConfigsHandler(svc))
|
||||
|
||||
@@ -63,3 +63,25 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// organizeSourcesHandler lists selectable organize source directories (download
|
||||
// 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()})
|
||||
}
|
||||
}
|
||||
|
||||
// organizeDirectoryHandler organizes an arbitrary source directory (e.g. the
|
||||
// download directory) into the destination with dedup + 洗版.
|
||||
func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
opts := bindOrganizeOptions(c)
|
||||
res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ import (
|
||||
|
||||
// OrganizerService moves/renames files into library structures.
|
||||
type OrganizerService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
probe *FFprobeService // optional; used for 洗版 resolution comparison
|
||||
}
|
||||
|
||||
// NewOrganizerService is the constructor.
|
||||
@@ -41,10 +42,16 @@ func NewOrganizerService(cfg *config.Config, log *zap.Logger, repo *repository.C
|
||||
return &OrganizerService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// SetProbe wires an FFprobe service so directory organize can compare real
|
||||
// pixel dimensions when deciding whether to 洗版 (replace by higher resolution).
|
||||
// Optional: when nil the organizer falls back to filename resolution tokens.
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// Package service — organize an arbitrary source directory (e.g. the download
|
||||
// directory) into the destination library with dedup + 洗版 (resolution
|
||||
// replacement).
|
||||
//
|
||||
// Unlike OrganizeLibraryWithOptions, which only touches model.Media rows that
|
||||
// already belong to a registered library, OrganizeDirectory walks the source
|
||||
// directory on disk directly. This lets operators organize the whole download
|
||||
// directory (/downloads or a NAS direct-read path like
|
||||
// /vol1/1000/qBittorrent/downloads) even though it is not a registered library.
|
||||
//
|
||||
// Two protections requested by operators:
|
||||
//
|
||||
// - 去重:目的地已存在同一媒体时不再从来源整理过去(避免重复 / 多倍占用存储)。
|
||||
// - 洗版:若来源分辨率高于目的地已存在的版本,则用高分辨率替换低分辨率。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// OrganizeSourceCandidate is a selectable organize source directory surfaced to
|
||||
// the UI so operators can organize an arbitrary directory (such as the download
|
||||
// directory) and not only registered libraries.
|
||||
type OrganizeSourceCandidate struct {
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"` // "download" | "media"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
out := []OrganizeSourceCandidate{}
|
||||
seen := map[string]struct{}{}
|
||||
add := func(label, path, kind string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "." || strings.HasPrefix(path, ".") {
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if _, ok := seen[clean]; ok {
|
||||
return
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind})
|
||||
}
|
||||
add("下载目录", envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download")
|
||||
add("媒体目录", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media"), "media")
|
||||
return out
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if r := strings.TrimSpace(override); r != "" {
|
||||
return r
|
||||
}
|
||||
if o.repo != nil && o.repo.Setting != nil {
|
||||
if v, err := o.repo.Setting.Get(ctx, "organize.target_dir"); err == nil && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")
|
||||
}
|
||||
|
||||
// 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)
|
||||
if source == "" {
|
||||
return nil, errors.New("source path required")
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if info, err := os.Stat(source); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("source directory not accessible: %s", source)
|
||||
}
|
||||
dest := filepath.Clean(o.defaultDestRoot(ctx, opts.DestPath))
|
||||
if dest == "" || dest == "." {
|
||||
return nil, errors.New("destination path required")
|
||||
}
|
||||
mode := o.resolveTransferMode(ctx, opts.TransferMode)
|
||||
res := &OrganizeResult{}
|
||||
walkErr := walk(source, func(path string, wi walkInfo) error {
|
||||
if wi.isDir {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if _, ok := videoExtensions[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
if err := o.organizeSourceFile(ctx, path, dest, mode, res); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return res, walkErr
|
||||
}
|
||||
o.log.Info("organize directory 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
|
||||
}
|
||||
|
||||
// organizeSourceFile organizes a single video file from the source directory
|
||||
// into destRoot, applying dedup + 洗版.
|
||||
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, destRoot string, mode TransferMode, res *OrganizeResult) error {
|
||||
ext := filepath.Ext(src)
|
||||
title, year := CleanQuery(src)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(src), ext)
|
||||
}
|
||||
// CleanQuery lowercases the parsed title; title-case it so organized output
|
||||
// matches typical library casing (and stays consistent for dedup).
|
||||
parsedTitle := title
|
||||
title = sanitizeFilename(titleCaseWords(title))
|
||||
if title == "" {
|
||||
title = "Unknown"
|
||||
}
|
||||
season, episode := ParseEpisode(src)
|
||||
|
||||
var destDir, dst, episodeTag string
|
||||
if season > 0 || episode > 0 {
|
||||
// TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext
|
||||
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
|
||||
destDir = filepath.Join(destRoot, title, fmt.Sprintf("Season %02d", season))
|
||||
dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
|
||||
} else {
|
||||
// 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext
|
||||
folder := title
|
||||
if year > 0 {
|
||||
folder = fmt.Sprintf("%s (%d)", title, year)
|
||||
}
|
||||
destDir = filepath.Join(destRoot, folder)
|
||||
dst = filepath.Join(destDir, folder+ext)
|
||||
}
|
||||
|
||||
// 源文件已经位于目标位置:无需处理。
|
||||
if filepath.Clean(src) == filepath.Clean(dst) {
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
// 去重候选:合并「目的地媒体库已扫描入库的同一媒体(按标题/年份/季集匹配,
|
||||
// 不受目录大小写或布局影响)」与「目标文件夹内已存在的同名视频文件」。
|
||||
existing := o.existingVersionPaths(ctx, destRoot, destDir, parsedTitle, episodeTag, year, season, episode)
|
||||
if len(existing) > 0 {
|
||||
srcArea := o.resolutionArea(ctx, src)
|
||||
bestArea := 0
|
||||
for _, e := range existing {
|
||||
if a := o.resolutionArea(ctx, e); a > bestArea {
|
||||
bestArea = a
|
||||
}
|
||||
}
|
||||
// 洗版:仅当来源与已存在版本的分辨率都可判定、且来源更高时才替换;
|
||||
// 任一方分辨率未知时保守跳过,绝不删除无法判定的已存在文件。
|
||||
if srcArea > 0 && bestArea > 0 && srcArea > bestArea {
|
||||
if err := o.replaceVersions(ctx, src, existing, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
o.log.Info("organize replaced lower-resolution media",
|
||||
zap.String("from", src),
|
||||
zap.String("to", dst),
|
||||
zap.Int("src_area", srcArea),
|
||||
zap.Int("existing_area", bestArea),
|
||||
)
|
||||
res.Replaced++
|
||||
return nil
|
||||
}
|
||||
// 去重:目的地已存在同一媒体且不低于来源分辨率,跳过不再整理过去。
|
||||
o.log.Debug("organize skip duplicate",
|
||||
zap.String("src", src), zap.String("dest_dir", destDir))
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := transferSidecarNFO(src, dst, mode); err != nil {
|
||||
o.log.Warn("organize sidecar nfo failed",
|
||||
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
||||
}
|
||||
res.Organized++
|
||||
return nil
|
||||
}
|
||||
|
||||
// existingVersionPaths returns existing destination files that represent the
|
||||
// same media, combining two strategies and de-duplicating by path:
|
||||
//
|
||||
// 1. DB identity: media rows already scanned into the destination root whose
|
||||
// title (case-insensitive) + year [or + season/episode] match the source.
|
||||
// This is robust to directory case/layout differences.
|
||||
// 2. Filesystem: video files inside the computed destination folder (matching
|
||||
// the SxxExx tag for episodes). Covers destinations that were not scanned.
|
||||
func (o *OrganizerService) existingVersionPaths(ctx context.Context, destRoot, destDir, title, episodeTag string, year, season, episode int) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
c := filepath.Clean(p)
|
||||
if _, ok := seen[c]; ok {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(c); err != nil {
|
||||
return
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, p := range o.existingByIdentity(ctx, destRoot, title, year, season, episode) {
|
||||
add(p)
|
||||
}
|
||||
for _, p := range o.existingByFolder(destDir, episodeTag) {
|
||||
add(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// existingByIdentity finds scanned destination media matching the parsed
|
||||
// identity (case-insensitive title + year for movies; title + season/episode
|
||||
// for episodes), located under destRoot.
|
||||
func (o *OrganizerService) existingByIdentity(ctx context.Context, destRoot, title string, year, season, episode int) []string {
|
||||
if o.repo == nil || o.repo.DB == nil {
|
||||
return nil
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
q := o.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("LOWER(title) = ?", strings.ToLower(title))
|
||||
if season > 0 || episode > 0 {
|
||||
q = q.Where("season_num = ? AND episode_num = ?", season, episode)
|
||||
} else if year > 0 {
|
||||
q = q.Where("year = ?", year)
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, r := range rows {
|
||||
if r.Path != "" && pathWithin(r.Path, destRoot) {
|
||||
out = append(out, r.Path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// existingByFolder returns video files already present in destDir that
|
||||
// represent the same media. For an episode (episodeTag != "") it matches files
|
||||
// carrying the same SxxExx tag; for a movie it matches every video file in the
|
||||
// movie folder.
|
||||
func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string {
|
||||
entries, err := os.ReadDir(destDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tag := strings.ToLower(episodeTag)
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(name))]; !ok {
|
||||
continue
|
||||
}
|
||||
if tag != "" && !strings.Contains(strings.ToLower(name), tag) {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(destDir, name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// titleCaseWords upper-cases the first letter of each ASCII word; CJK and other
|
||||
// non-ASCII leading characters are left untouched.
|
||||
func titleCaseWords(s string) string {
|
||||
fields := strings.Fields(s)
|
||||
for i, w := range fields {
|
||||
r := []rune(w)
|
||||
if len(r) > 0 && r[0] < 128 {
|
||||
r[0] = unicode.ToUpper(r[0])
|
||||
fields[i] = string(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// replaceVersions removes the existing lower-resolution files (and their NFO
|
||||
// sidecars + DB rows) and transfers src into dst.
|
||||
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
|
||||
for _, e := range existing {
|
||||
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove existing %s: %w", e, err)
|
||||
}
|
||||
if nfo := nfoPath(e); nfo != "" {
|
||||
_ = os.Remove(nfo)
|
||||
}
|
||||
if o.repo != nil && o.repo.DB != nil {
|
||||
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := transferSidecarNFO(src, dst, mode); err != nil {
|
||||
o.log.Warn("organize sidecar nfo failed",
|
||||
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolutionArea returns the pixel area (width*height) of a video file for 洗版
|
||||
// comparison. It prefers ffprobe; when unavailable it falls back to a
|
||||
// resolution token in the filename (2160p/1080p/720p). Returns 0 when the
|
||||
// resolution cannot be determined, in which case the caller treats the file as
|
||||
// "unknown" and never performs a destructive replace.
|
||||
func (o *OrganizerService) resolutionArea(ctx context.Context, path string) int {
|
||||
// Prefer a scanned media row's stored dimensions. The destination library
|
||||
// is normally scanned with ffprobe, so its files have accurate Width/Height
|
||||
// even after organize stripped the resolution token from the filename.
|
||||
if o.repo != nil && o.repo.DB != nil {
|
||||
var m model.Media
|
||||
if err := o.repo.DB.WithContext(ctx).
|
||||
Select("width", "height").
|
||||
Where("path = ?", path).
|
||||
Limit(1).Take(&m).Error; err == nil && m.Width > 0 && m.Height > 0 {
|
||||
return m.Width * m.Height
|
||||
}
|
||||
}
|
||||
if o.probe != nil {
|
||||
if pr, err := o.probe.Probe(ctx, path); err == nil && pr != nil && pr.Width > 0 && pr.Height > 0 {
|
||||
return pr.Width * pr.Height
|
||||
}
|
||||
}
|
||||
switch detectResolutionScore(strings.ToLower(filepath.Base(path))) {
|
||||
case 4:
|
||||
return 3840 * 2160
|
||||
case 3:
|
||||
return 1920 * 1080
|
||||
case 2:
|
||||
return 1280 * 720
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
func writeOrgFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrganizeDirectoryNewMedia organizes a brand-new movie from a source
|
||||
// directory (e.g. the download dir) into the destination — no library row
|
||||
// required.
|
||||
func TestOrganizeDirectoryNewMedia(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")
|
||||
|
||||
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 directory: %v", err)
|
||||
}
|
||||
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")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected organized file at %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrganizeDirectoryDedup verifies that media already present in the
|
||||
// destination is NOT organized again from the source (去重), and the existing
|
||||
// file is left untouched.
|
||||
func TestOrganizeDirectoryDedup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
dest := filepath.Join(root, "media")
|
||||
|
||||
// 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")
|
||||
writeOrgFile(t, existing, "matrix-existing")
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
// Scanned destination row carries real 1080p dimensions.
|
||||
row := model.Media{Title: "The Matrix", Path: existing, Year: 1999, Container: "mkv", Width: 1920, Height: 1080}
|
||||
if err := repos.Media.Upsert(t.Context(), &row); 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 != 0 || res.Replaced != 0 || res.Skipped != 1 {
|
||||
t.Fatalf("expected organized=0 replaced=0 skipped=1 (dedup), got %+v", res)
|
||||
}
|
||||
// Existing destination file must be untouched.
|
||||
got, err := os.ReadFile(existing)
|
||||
if err != nil || string(got) != "matrix-existing" {
|
||||
t.Fatalf("existing file must be untouched, got %q err=%v", string(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrganizeDirectoryReplaceHigherResolution verifies 洗版: a higher-resolution
|
||||
// source replaces the lower-resolution version already in the destination.
|
||||
func TestOrganizeDirectoryReplaceHigherResolution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
dest := filepath.Join(root, "media")
|
||||
|
||||
// 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")
|
||||
writeOrgFile(t, existing, "inception-1080p")
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
row := model.Media{Title: "Inception", Path: existing, Year: 2010, Container: "mkv", Width: 1920, Height: 1080}
|
||||
if err := repos.Media.Upsert(t.Context(), &row); 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.Replaced != 1 || res.Organized != 0 || res.Skipped != 0 {
|
||||
t.Fatalf("expected replaced=1 organized=0 skipped=0 (洗版), got %+v", res)
|
||||
}
|
||||
// Destination file must now contain the higher-resolution source content.
|
||||
got, err := os.ReadFile(existing)
|
||||
if err != nil || string(got) != "inception-uhd" {
|
||||
t.Fatalf("destination must hold the higher-res source content, got %q err=%v", string(got), err)
|
||||
}
|
||||
// The replaced DB row should be gone.
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.Media{}).Where("path = ?", existing).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected replaced media DB row removed, found %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrganizeDirectoryKeepsHigherResolutionExisting verifies that a LOWER-res
|
||||
// source does NOT replace a higher-res existing file (it is treated as a
|
||||
// duplicate and skipped).
|
||||
func TestOrganizeDirectoryKeepsHigherResolutionExisting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
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")
|
||||
writeOrgFile(t, existing, "inception-2160p")
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
row := model.Media{Title: "Inception", Path: existing, Year: 2010, Container: "mkv", Width: 3840, Height: 2160}
|
||||
if err := repos.Media.Upsert(t.Context(), &row); 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.Skipped != 1 || res.Replaced != 0 || res.Organized != 0 {
|
||||
t.Fatalf("expected skipped=1 (keep higher-res existing), got %+v", res)
|
||||
}
|
||||
got, err := os.ReadFile(existing)
|
||||
if err != nil || string(got) != "inception-2160p" {
|
||||
t.Fatalf("higher-res existing must be kept, got %q err=%v", string(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrganizeDirectoryTVEpisodeDedup verifies per-episode dedup for TV media.
|
||||
func TestOrganizeDirectoryTVEpisodeDedup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads")
|
||||
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")
|
||||
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")
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
row := model.Media{Title: "Friends", Path: existing, SeasonNum: 1, EpisodeNum: 1, Container: "mkv", Width: 1920, Height: 1080}
|
||||
if err := repos.Media.Upsert(t.Context(), &row); 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)
|
||||
}
|
||||
// E01 deduped (skipped), E02 organized fresh.
|
||||
if res.Organized != 1 || res.Skipped != 1 || res.Replaced != 0 {
|
||||
t.Fatalf("expected organized=1 skipped=1 replaced=0, got %+v", res)
|
||||
}
|
||||
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")
|
||||
if _, err := os.Stat(e02); err != nil {
|
||||
t.Fatalf("expected E02 organized at %q: %v", e02, err)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
adult := NewAdultProvider(log, apiConfig)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult)
|
||||
organizer := NewOrganizerService(cfg, log, repos)
|
||||
organizer.SetProbe(probe)
|
||||
discover := NewDiscoverService(log, tmdb)
|
||||
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||
|
||||
@@ -12,6 +12,15 @@ export interface OrganizeOverrides {
|
||||
transfer_mode?: string
|
||||
}
|
||||
|
||||
// OrganizeSource is a selectable organize source directory (e.g. the download
|
||||
// directory) surfaced so operators can organize an arbitrary directory and not
|
||||
// only registered libraries.
|
||||
export interface OrganizeSource {
|
||||
label: string
|
||||
path: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
export const toolsAPI = {
|
||||
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
||||
api
|
||||
@@ -26,6 +35,22 @@ export const toolsAPI = {
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
// organizeSources lists selectable source directories (download/media dir).
|
||||
organizeSources: () =>
|
||||
api
|
||||
.get<{ sources: OrganizeSource[] }>('/admin/organize/sources')
|
||||
.then((r) => r.data.sources ?? []),
|
||||
|
||||
// organizeDirectory organizes an arbitrary source directory (e.g. downloads)
|
||||
// into the destination with dedup + 洗版 (resolution replacement).
|
||||
organizeDirectory: (opts: OrganizeOverrides) =>
|
||||
api
|
||||
.post<{ organized: number; skipped: number; replaced?: number; errors?: string[] }>(
|
||||
'/admin/organize/source',
|
||||
opts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
notifyTest: (title: string, body: string) =>
|
||||
api
|
||||
.post<{ message: string }>('/admin/notify/test', { title, body })
|
||||
|
||||
@@ -4,7 +4,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { toolsAPI, type OrganizeSource } from '../api/tools'
|
||||
import type { Library, Media, Setting } from '../types'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
@@ -66,6 +66,11 @@ function OrganizePanel() {
|
||||
const [destPath, setDestPath] = useState('')
|
||||
const [transferMode, setTransferMode] = useState('')
|
||||
|
||||
// 整理来源目录(如下载目录):可选择整个目录作为整理源,不要求是已登记媒体库。
|
||||
const [sources, setSources] = useState<OrganizeSource[]>([])
|
||||
const [sourceDir, setSourceDir] = useState('')
|
||||
const [organizingDir, setOrganizingDir] = useState(false)
|
||||
|
||||
const overrides = () => {
|
||||
const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {}
|
||||
if (sourcePath.trim()) o.source_path = sourcePath.trim()
|
||||
@@ -76,6 +81,13 @@ function OrganizePanel() {
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
toolsAPI
|
||||
.organizeSources()
|
||||
.then((s) => {
|
||||
setSources(s)
|
||||
if (s.length > 0) setSourceDir(s[0].path)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,6 +124,34 @@ function OrganizePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const onOrganizeDir = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const src = (sourceDir || sourcePath).trim()
|
||||
if (!src) {
|
||||
toast.error('请选择或填写整理来源目录')
|
||||
return
|
||||
}
|
||||
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
|
||||
toast.success(
|
||||
`整理完成:新增 ${r.organized} · 替换(洗版) ${replaced} · 去重跳过 ${r.skipped}`,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'整理失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setOrganizingDir(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = async (e?: FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!query.trim()) return
|
||||
@@ -203,6 +243,40 @@ function OrganizePanel() {
|
||||
</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 || !(sourceDir || sourcePath).trim()}
|
||||
className="neon-button"
|
||||
>
|
||||
{organizingDir ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FolderCog size={16} />
|
||||
)}
|
||||
整理来源目录(去重+洗版)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
|
||||
<select
|
||||
required
|
||||
|
||||
Reference in New Issue
Block a user