From d03f84e78d1157ba8cf2c9eddcc391e8e44b6db7 Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 08:00:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(organize):=20organize=20arbitrary=20source?= =?UTF-8?q?=20dir=20(e.g.=20downloads)=20with=20dedup=20+=20=E6=B4=97?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- internal/handler/handler.go | 2 + internal/handler/organizer.go | 22 ++ internal/service/organizer.go | 13 +- internal/service/organizer_directory.go | 378 +++++++++++++++++++ internal/service/organizer_directory_test.go | 211 +++++++++++ internal/service/service.go | 1 + web/src/api/tools.ts | 25 ++ web/src/pages/ToolsPage.tsx | 76 +++- 8 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 internal/service/organizer_directory.go create mode 100644 internal/service/organizer_directory_test.go diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 731ae2d..ef076d4 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index 73607b2..c548492 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -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) + } +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 5e4018b..1b320ca 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -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"` } diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go new file mode 100644 index 0000000..de6dedb --- /dev/null +++ b/internal/service/organizer_directory.go @@ -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 + } +} diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go new file mode 100644 index 0000000..9b06bed --- /dev/null +++ b/internal/service/organizer_directory_test.go @@ -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) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 59cc4c5..10583bb 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index fffd8dd..536dd22 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -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 }) diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index 5193857..256b332 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -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([]) + 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() { +
+

+ 整理来源目录:直接整理整个目录(如下载目录 /downloads),无需是已登记的媒体库。已存在于目的地的媒体会自动去重跳过,来源分辨率更高时会替换(洗版)旧版本。目的地留空则使用「整理目的地目录」设置或媒体目录。 +

+
+ + +
+
+