mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36:37 +08:00
feat(organize): separate source dir from destination dir (从源目录整理到目的地目录)
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>
This commit is contained in:
@@ -11,8 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// organizeReq carries optional per-request overrides. 留空则沿用系统设置。
|
// organizeReq carries optional per-request overrides. 留空则沿用系统设置。
|
||||||
|
//
|
||||||
|
// source_path = 源目录(待整理),dest_path = 目的地目录(整理输出)。
|
||||||
|
// target_path 为 dest_path 的向后兼容别名。
|
||||||
type organizeReq struct {
|
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"`
|
TransferMode string `json:"transfer_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +26,14 @@ type organizeReq struct {
|
|||||||
func bindOrganizeOptions(c *gin.Context) service.OrganizeOptions {
|
func bindOrganizeOptions(c *gin.Context) service.OrganizeOptions {
|
||||||
var req organizeReq
|
var req organizeReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = 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 != "" {
|
if m := strings.TrimSpace(req.TransferMode); m != "" {
|
||||||
opts.TransferMode = service.TransferMode(m)
|
opts.TransferMode = service.TransferMode(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,16 @@ type OrganizeResult struct {
|
|||||||
|
|
||||||
// OrganizeOptions carries per-request overrides for an organize operation.
|
// OrganizeOptions carries per-request overrides for an organize operation.
|
||||||
// 空值表示沿用系统设置中的默认值。
|
// 空值表示沿用系统设置中的默认值。
|
||||||
|
//
|
||||||
|
// 整理是「从源目录整理到目的地目录」:SourcePath 指定待整理文件所在的源目录,
|
||||||
|
// DestPath 指定整理输出的目的地目录。两者相互独立,不再混用同一个目录。
|
||||||
type OrganizeOptions struct {
|
type OrganizeOptions struct {
|
||||||
// TargetPath 本次整理的目标根路径,覆盖 organize.target_dir 设置与媒体库路径。
|
// SourcePath 本次整理的源目录(待整理文件所在目录),覆盖 organize.source_dir
|
||||||
TargetPath string
|
// 设置与媒体库路径。仅整理位于该目录下的媒体;留空表示整个媒体库。
|
||||||
|
SourcePath string
|
||||||
|
// DestPath 本次整理的目的地根路径(整理输出到哪里),覆盖 organize.target_dir 设置。
|
||||||
|
// 留空则使用设置中的默认目的地目录,再退回媒体库路径。
|
||||||
|
DestPath string
|
||||||
// TransferMode 本次整理的转移方式,覆盖 organize.transfer_mode 设置。
|
// TransferMode 本次整理的转移方式,覆盖 organize.transfer_mode 设置。
|
||||||
TransferMode TransferMode
|
TransferMode TransferMode
|
||||||
}
|
}
|
||||||
@@ -76,7 +83,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
if err != nil || lib == nil {
|
if err != nil || lib == nil {
|
||||||
return "", errors.New("library not found")
|
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)
|
mode := o.resolveTransferMode(ctx, opts.TransferMode)
|
||||||
if isSeriesLibraryType(lib.Type) {
|
if isSeriesLibraryType(lib.Type) {
|
||||||
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
||||||
@@ -166,8 +173,9 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
return dst, nil
|
return dst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBaseRoot picks the organize target root: a per-request override
|
// resolveBaseRoot picks the organize destination root (目的地目录): a
|
||||||
// wins, then the organize.target_dir setting, then the library's own path.
|
// 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 {
|
func (o *OrganizerService) resolveBaseRoot(ctx context.Context, lib *model.Library, override string) string {
|
||||||
if r := strings.TrimSpace(override); r != "" {
|
if r := strings.TrimSpace(override); r != "" {
|
||||||
return r
|
return r
|
||||||
@@ -180,6 +188,22 @@ func (o *OrganizerService) resolveBaseRoot(ctx context.Context, lib *model.Libra
|
|||||||
return lib.Path
|
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,
|
// resolveTransferMode picks the transfer mode: a per-request override wins,
|
||||||
// otherwise the organize.transfer_mode setting (default move). When the
|
// otherwise the organize.transfer_mode setting (default move). When the
|
||||||
// effective mode is move and 做种保种 (organize.keep_seeding) is enabled, it is
|
// 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 {
|
Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
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{}
|
res := &OrganizeResult{}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
|
// 不在源目录内的文件跳过(不属于本次「从源目录整理」的范围)。
|
||||||
|
if !pathWithin(rows[i].Path, sourceRoot) {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
if pathWithin(rows[i].Path, baseRoot) {
|
if pathWithin(rows[i].Path, baseRoot) {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import { api } from './client'
|
|||||||
// dispatching a test notification through the configured channels.
|
// dispatching a test notification through the configured channels.
|
||||||
// OrganizeOverrides are optional single-request overrides for an organize
|
// OrganizeOverrides are optional single-request overrides for an organize
|
||||||
// action. Empty fields fall back to the system settings.
|
// action. Empty fields fall back to the system settings.
|
||||||
|
// source_path = 源目录(待整理文件所在),dest_path = 目的地目录(整理输出到哪里)。
|
||||||
export interface OrganizeOverrides {
|
export interface OrganizeOverrides {
|
||||||
target_path?: string
|
source_path?: string
|
||||||
|
dest_path?: string
|
||||||
transfer_mode?: string
|
transfer_mode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,17 @@ const GROUPS: SettingGroup[] = [
|
|||||||
hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)',
|
hint: '根据元数据(语言/国家/类型)自动分类到子目录(如:华语电影、欧美剧、日番)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'organize.target_dir',
|
key: 'organize.source_dir',
|
||||||
label: '整理目标目录',
|
label: '整理源目录(待整理)',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
hint: '留空则默认整理到各媒体库对应路径(见下方参考)',
|
hint: '从该目录读取待整理文件;留空则默认整理整个媒体库(媒体库路径)。',
|
||||||
|
placeholder: '/mnt/downloads',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'organize.target_dir',
|
||||||
|
label: '整理目的地目录',
|
||||||
|
type: 'text',
|
||||||
|
hint: '整理后输出到该目录;留空则默认整理到各媒体库对应路径(见下方参考)。与「源目录」相互独立。',
|
||||||
placeholder: '/mnt/media/organized',
|
placeholder: '/mnt/media/organized',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -388,7 +395,7 @@ export function SettingsPage() {
|
|||||||
<div className="glass-panel">
|
<div className="glass-panel">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
|
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
|
||||||
<FolderOpen size={16} className="text-brand-500" />
|
<FolderOpen size={16} className="text-brand-500" />
|
||||||
<span>默认整理路径参考(未设目标目录时按媒体库归类)</span>
|
<span>默认整理路径参考(未设目的地目录时按媒体库归类)</span>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||||
|
|||||||
@@ -61,13 +61,15 @@ function OrganizePanel() {
|
|||||||
const [smartClassify, setSmartClassify] = useState(false)
|
const [smartClassify, setSmartClassify] = useState(false)
|
||||||
const [loadingSettings, setLoadingSettings] = useState(true)
|
const [loadingSettings, setLoadingSettings] = useState(true)
|
||||||
|
|
||||||
// 单次整理覆盖项:留空则沿用设置页的默认整理目录与转移方式。
|
// 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。
|
||||||
const [targetPath, setTargetPath] = useState('')
|
const [sourcePath, setSourcePath] = useState('')
|
||||||
|
const [destPath, setDestPath] = useState('')
|
||||||
const [transferMode, setTransferMode] = useState('')
|
const [transferMode, setTransferMode] = useState('')
|
||||||
|
|
||||||
const overrides = () => {
|
const overrides = () => {
|
||||||
const o: { target_path?: string; transfer_mode?: string } = {}
|
const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {}
|
||||||
if (targetPath.trim()) o.target_path = targetPath.trim()
|
if (sourcePath.trim()) o.source_path = sourcePath.trim()
|
||||||
|
if (destPath.trim()) o.dest_path = destPath.trim()
|
||||||
if (transferMode) o.transfer_mode = transferMode
|
if (transferMode) o.transfer_mode = transferMode
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
@@ -162,14 +164,27 @@ function OrganizePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 sm:grid-cols-2">
|
<p className="text-xs text-sand-500">
|
||||||
|
整理是「<b>从源目录整理到目的地目录</b>」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-ink-50">整理目标目录(可选,覆盖默认设置)</span>
|
<span className="text-xs text-ink-50">源目录(待整理,可选)</span>
|
||||||
<input
|
<input
|
||||||
className="input-base w-full"
|
className="input-base w-full"
|
||||||
placeholder="留空则使用「整理目标目录」设置或媒体库路径"
|
placeholder="留空则使用「整理源目录」设置或媒体库路径"
|
||||||
value={targetPath}
|
value={sourcePath}
|
||||||
onChange={(e) => setTargetPath(e.target.value)}
|
onChange={(e) => setSourcePath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">目的地目录(整理输出,可选)</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full"
|
||||||
|
placeholder="留空则使用「整理目的地目录」设置或媒体库路径"
|
||||||
|
value={destPath}
|
||||||
|
onChange={(e) => setDestPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user