From 8f0c6d23240856f96403de8b02cde8778a215320 Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 04:43:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(organize):=20separate=20source=20dir=20fro?= =?UTF-8?q?m=20destination=20dir=20(=E4=BB=8E=E6=BA=90=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=95=B4=E7=90=86=E5=88=B0=E7=9B=AE=E7=9A=84=E5=9C=B0=E7=9B=AE?= =?UTF-8?q?=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously organize only exposed a single 'target dir' that was actually the destination, conflating 源目录 (where files to organize live) with 目的地目录 (where organized files go). Add an explicit source directory: - OrganizeOptions gains SourcePath; DestPath replaces the old TargetPath (destination) for clarity. New organize.source_dir setting + source_path request override; resolveSourceRoot falls back to library path. - OrganizeLibraryWithOptions only organizes media located under the source root, so operators can point at a specific download/staging folder and organize into a distinct destination. - Handler accepts source_path/dest_path (target_path kept as a deprecated alias for the destination, backward compatible). - Settings page splits into 整理源目录(待整理) + 整理目的地目录; Tools organize panel exposes 源目录 + 目的地目录 inputs with clear copy. Defaults are unchanged (source = destination = library path) so existing setups behave identically. Adds a regression test that organize is scoped to the source directory. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/handler/organizer.go | 16 ++++- internal/service/organizer.go | 45 ++++++++++-- internal/service/organizer_source_test.go | 85 +++++++++++++++++++++++ web/src/api/tools.ts | 4 +- web/src/pages/SettingsPage.tsx | 15 ++-- web/src/pages/ToolsPage.tsx | 33 ++++++--- 6 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 internal/service/organizer_source_test.go diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index c7fe91f..73607b2 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -11,8 +11,13 @@ import ( ) // organizeReq carries optional per-request overrides. 留空则沿用系统设置。 +// +// source_path = 源目录(待整理),dest_path = 目的地目录(整理输出)。 +// target_path 为 dest_path 的向后兼容别名。 type organizeReq struct { - TargetPath string `json:"target_path"` + SourcePath string `json:"source_path"` + DestPath string `json:"dest_path"` + TargetPath string `json:"target_path"` // deprecated alias for dest_path TransferMode string `json:"transfer_mode"` } @@ -21,7 +26,14 @@ type organizeReq struct { func bindOrganizeOptions(c *gin.Context) service.OrganizeOptions { var req organizeReq _ = c.ShouldBindJSON(&req) - opts := service.OrganizeOptions{TargetPath: strings.TrimSpace(req.TargetPath)} + dest := strings.TrimSpace(req.DestPath) + if dest == "" { + dest = strings.TrimSpace(req.TargetPath) + } + opts := service.OrganizeOptions{ + SourcePath: strings.TrimSpace(req.SourcePath), + DestPath: dest, + } if m := strings.TrimSpace(req.TransferMode); m != "" { opts.TransferMode = service.TransferMode(m) } diff --git a/internal/service/organizer.go b/internal/service/organizer.go index adc509b..5e4018b 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -50,9 +50,16 @@ type OrganizeResult struct { // OrganizeOptions carries per-request overrides for an organize operation. // 空值表示沿用系统设置中的默认值。 +// +// 整理是「从源目录整理到目的地目录」:SourcePath 指定待整理文件所在的源目录, +// DestPath 指定整理输出的目的地目录。两者相互独立,不再混用同一个目录。 type OrganizeOptions struct { - // TargetPath 本次整理的目标根路径,覆盖 organize.target_dir 设置与媒体库路径。 - TargetPath string + // SourcePath 本次整理的源目录(待整理文件所在目录),覆盖 organize.source_dir + // 设置与媒体库路径。仅整理位于该目录下的媒体;留空表示整个媒体库。 + SourcePath string + // DestPath 本次整理的目的地根路径(整理输出到哪里),覆盖 organize.target_dir 设置。 + // 留空则使用设置中的默认目的地目录,再退回媒体库路径。 + DestPath string // TransferMode 本次整理的转移方式,覆盖 organize.transfer_mode 设置。 TransferMode TransferMode } @@ -76,7 +83,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID if err != nil || lib == nil { return "", errors.New("library not found") } - baseRoot := o.resolveBaseRoot(ctx, lib, opts.TargetPath) + baseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath) mode := o.resolveTransferMode(ctx, opts.TransferMode) if isSeriesLibraryType(lib.Type) { if err := o.refreshEpisodeIdentity(m, lib); err != nil { @@ -166,8 +173,9 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID return dst, nil } -// resolveBaseRoot picks the organize target root: a per-request override -// wins, then the organize.target_dir setting, then the library's own path. +// resolveBaseRoot picks the organize destination root (目的地目录): a +// per-request override wins, then the organize.target_dir setting, then the +// library's own path. func (o *OrganizerService) resolveBaseRoot(ctx context.Context, lib *model.Library, override string) string { if r := strings.TrimSpace(override); r != "" { return r @@ -180,6 +188,22 @@ func (o *OrganizerService) resolveBaseRoot(ctx context.Context, lib *model.Libra return lib.Path } +// resolveSourceRoot picks the organize source root (源目录,待整理文件所在目录): +// a per-request override wins, then the organize.source_dir setting, then the +// library's own path. Library organize only touches media located under this +// root, so operators can point at a specific download/staging folder. +func (o *OrganizerService) resolveSourceRoot(ctx context.Context, lib *model.Library, 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.source_dir"); err == nil && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return lib.Path +} + // resolveTransferMode picks the transfer mode: a per-request override wins, // otherwise the organize.transfer_mode setting (default move). When the // effective mode is move and 做种保种 (organize.keep_seeding) is enabled, it is @@ -234,10 +258,17 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra Find(&rows).Error; err != nil { return nil, err } - // 已位于整理目标根下的文件视为已整理;目标根受 target_path 覆盖与设置影响。 - baseRoot := o.resolveBaseRoot(ctx, lib, opts.TargetPath) + // 源目录(待整理):仅整理位于该目录下的媒体;留空 = 整个媒体库。 + sourceRoot := o.resolveSourceRoot(ctx, lib, opts.SourcePath) + // 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。 + baseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath) res := &OrganizeResult{} for i := range rows { + // 不在源目录内的文件跳过(不属于本次「从源目录整理」的范围)。 + if !pathWithin(rows[i].Path, sourceRoot) { + res.Skipped++ + continue + } if pathWithin(rows[i].Path, baseRoot) { res.Skipped++ continue diff --git a/internal/service/organizer_source_test.go b/internal/service/organizer_source_test.go new file mode 100644 index 0000000..085cc1e --- /dev/null +++ b/internal/service/organizer_source_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// TestOrganizeLibraryScopesToSourceDir verifies organize reads from the +// source directory (源目录) and writes to the destination directory (目的地目录): +// only media located under SourcePath are organized; media outside it are +// left untouched. +func TestOrganizeLibraryScopesToSourceDir(t *testing.T) { + root := t.TempDir() + srcDir := filepath.Join(root, "downloads") + otherDir := filepath.Join(root, "elsewhere") + dest := filepath.Join(root, "library") + for _, d := range []string{srcDir, otherDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + inSource := filepath.Join(srcDir, "In Source.mkv") + outside := filepath.Join(otherDir, "Outside.mkv") + if err := os.WriteFile(inSource, []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(outside, []byte("b"), 0o644); err != nil { + t.Fatal(err) + } + + repos := newOrganizerTestRepo(t) + lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + mIn := model.Media{LibraryID: lib.ID, Title: "In Source", Path: inSource, Year: 2020, Container: "mkv", ScrapeStatus: "matched"} + mOut := model.Media{LibraryID: lib.ID, Title: "Outside", Path: outside, Year: 2021, Container: "mkv", ScrapeStatus: "matched"} + if err := repos.Media.Upsert(t.Context(), &mIn); err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &mOut); err != nil { + t.Fatal(err) + } + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeLibraryWithOptions(t.Context(), lib.ID, OrganizeOptions{ + SourcePath: srcDir, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize: %v", err) + } + if res.Organized != 1 { + t.Fatalf("expected exactly 1 organized (only the in-source media), got %d (skipped %d)", res.Organized, res.Skipped) + } + + // The in-source media should now live under the destination dir. + gotIn, err := repos.Media.FindByID(t.Context(), mIn.ID) + if err != nil || gotIn == nil { + t.Fatalf("reload in-source media: %v", err) + } + if !strings.HasPrefix(gotIn.Path, dest) { + t.Fatalf("in-source media should be organized under dest %q, got %q", dest, gotIn.Path) + } + + // The outside media must be left untouched (not moved into dest). + gotOut, err := repos.Media.FindByID(t.Context(), mOut.ID) + if err != nil || gotOut == nil { + t.Fatalf("reload outside media: %v", err) + } + if gotOut.Path != outside { + t.Fatalf("outside media must not be organized; path changed to %q", gotOut.Path) + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("outside source file must remain in place: %v", err) + } +} diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 0817bba..fffd8dd 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -5,8 +5,10 @@ import { api } from './client' // dispatching a test notification through the configured channels. // OrganizeOverrides are optional single-request overrides for an organize // action. Empty fields fall back to the system settings. +// source_path = 源目录(待整理文件所在),dest_path = 目的地目录(整理输出到哪里)。 export interface OrganizeOverrides { - target_path?: string + source_path?: string + dest_path?: string transfer_mode?: string } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index d844b77..bf7b6d2 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -152,10 +152,17 @@ const GROUPS: SettingGroup[] = [ hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)', }, { - key: 'organize.target_dir', - label: '整理目标目录', + key: 'organize.source_dir', + label: '整理源目录(待整理)', type: 'text', - hint: '留空则默认整理到各媒体库对应路径(见下方参考)', + hint: '从该目录读取待整理文件;留空则默认整理整个媒体库(媒体库路径)。', + placeholder: '/mnt/downloads', + }, + { + key: 'organize.target_dir', + label: '整理目的地目录', + type: 'text', + hint: '整理后输出到该目录;留空则默认整理到各媒体库对应路径(见下方参考)。与「源目录」相互独立。', placeholder: '/mnt/media/organized', }, { @@ -388,7 +395,7 @@ export function SettingsPage() {
- 默认整理路径参考(未设目标目录时按媒体库归类) + 默认整理路径参考(未设目的地目录时按媒体库归类)
diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index a29e32b..5193857 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -61,13 +61,15 @@ function OrganizePanel() { const [smartClassify, setSmartClassify] = useState(false) const [loadingSettings, setLoadingSettings] = useState(true) - // 单次整理覆盖项:留空则沿用设置页的默认整理目录与转移方式。 - const [targetPath, setTargetPath] = useState('') + // 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。 + const [sourcePath, setSourcePath] = useState('') + const [destPath, setDestPath] = useState('') const [transferMode, setTransferMode] = useState('') const overrides = () => { - const o: { target_path?: string; transfer_mode?: string } = {} - if (targetPath.trim()) o.target_path = targetPath.trim() + const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {} + if (sourcePath.trim()) o.source_path = sourcePath.trim() + if (destPath.trim()) o.dest_path = destPath.trim() if (transferMode) o.transfer_mode = transferMode return o } @@ -162,14 +164,27 @@ function OrganizePanel() { )} -
+

+ 整理是「从源目录整理到目的地目录」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。 +

+ +
+