fix: normalize organize category roots

This commit is contained in:
ShukeBta
2026-06-25 08:54:42 +08:00
parent 89af1dad32
commit 16c3ed239a
10 changed files with 213 additions and 15 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount") return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount")
} }
// 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。 // 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
if _, ok := ParseCloudLibraryMount(baseRoot); ok { if _, ok := ParseCloudLibraryMount(baseRoot); ok {
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
} }
+1 -1
View File
@@ -48,7 +48,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
if _, ok := ParseCloudLibraryMount(requestedDest); ok { if _, ok := ParseCloudLibraryMount(requestedDest); ok {
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
} }
dest := redirectOrganizeStagingRoot(resolveMappedDestinationPath(requestedDest)) dest := normalizeOrganizeDestinationRoot(resolveMappedDestinationPath(requestedDest))
if dest == "" || dest == "." { if dest == "" || dest == "." {
return nil, errors.New("destination path required") return nil, errors.New("destination path required")
} }
@@ -80,6 +80,49 @@ func TestOrganizeDirectoryUsesExplicitCategoryLibraryRoot(t *testing.T) {
} }
} }
func TestOrganizeDirectoryTreatsCategoryDestAsCollectionRoot(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "downloads", "Some.Show.S01E01.2026.1080p.mkv")
dest := filepath.Join(root, "media")
writeOrgFile(t, src, "episode")
repos := newOrganizerTestRepo(t)
currentLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &currentLib); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: currentLib.Path,
MediaType: "tv",
MediaCategory: "国产剧",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize category-root dest: %v", err)
}
if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized item", res)
}
wantRoot := filepath.Join(dest, "电视剧", "国产剧")
want := filepath.Join(wantRoot, "Some Show", "Season 01", "Some Show - S01E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected sibling category target at %q: %v; items=%#v", want, err, res.Items)
}
nested := filepath.Join(currentLib.Path, "国产剧", "Some Show", "Season 01", "Some Show - S01E01.mkv")
if _, err := os.Stat(nested); !os.IsNotExist(err) {
t.Fatalf("must not nest corrected category under current library, stat err=%v", err)
}
var created model.Library
if err := repos.DB.Where("path = ?", wantRoot).First(&created).Error; err != nil {
t.Fatalf("corrected category library should be auto-created: %v", err)
}
}
func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.T) { func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.T) {
root := t.TempDir() root := t.TempDir()
srcRoot := filepath.Join(root, "downloads") srcRoot := filepath.Join(root, "downloads")
@@ -161,24 +161,26 @@ func pathHasDirectChild(path, root, child string) bool {
return strings.EqualFold(parts[0], child) return strings.EqualFold(parts[0], child)
} }
func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, root, mediaType, category string) { func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, root, mediaType, category string) (model.Library, bool) {
if o == nil || o.repo == nil || o.repo.Library == nil { if o == nil || o.repo == nil || o.repo.Library == nil {
return return model.Library{}, false
} }
root = filepath.Clean(strings.TrimSpace(root)) root = filepath.Clean(strings.TrimSpace(root))
if root == "" || root == "." { if root == "" || root == "." {
return return model.Library{}, false
} }
if _, ok := ParseCloudLibraryMount(root); ok { if _, ok := ParseCloudLibraryMount(root); ok {
return return model.Library{}, false
} }
libraries, err := o.repo.Library.List(ctx) libraries, err := o.repo.Library.List(ctx)
if err != nil { if err != nil {
if o.log != nil { if o.log != nil {
o.log.Debug("list libraries before organize auto-create failed", zap.Error(err)) o.log.Debug("list libraries before organize auto-create failed", zap.Error(err))
} }
return return model.Library{}, false
} }
var containingLibrary model.Library
hasContainingLibrary := false
for _, lib := range libraries { for _, lib := range libraries {
if !lib.Enabled || strings.TrimSpace(lib.Path) == "" { if !lib.Enabled || strings.TrimSpace(lib.Path) == "" {
continue continue
@@ -186,9 +188,17 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
if _, ok := ParseCloudLibraryMount(lib.Path); ok { if _, ok := ParseCloudLibraryMount(lib.Path); ok {
continue continue
} }
if pathWithin(root, lib.Path) { lib.Path = filepath.Clean(lib.Path)
return if strings.EqualFold(lib.Path, root) {
return lib, true
} }
if pathWithin(root, lib.Path) {
containingLibrary = lib
hasContainingLibrary = true
}
}
if strings.TrimSpace(category) == "" && hasContainingLibrary {
return containingLibrary, true
} }
name := strings.TrimSpace(category) name := strings.TrimSpace(category)
if name == "" { if name == "" {
@@ -211,7 +221,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
zap.String("name", lib.Name), zap.String("name", lib.Name),
zap.Error(err)) zap.Error(err))
} }
return return model.Library{}, false
} }
if o.log != nil { if o.log != nil {
o.log.Info("organize auto-created missing library", o.log.Info("organize auto-created missing library",
@@ -219,6 +229,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
zap.String("type", lib.Type), zap.String("type", lib.Type),
zap.String("name", lib.Name)) zap.String("name", lib.Name))
} }
return lib, true
} }
func organizeLibraryModelType(mediaType string) string { func organizeLibraryModelType(mediaType string) string {
@@ -210,7 +210,9 @@ func (o *OrganizerService) resolveOrganizeSourceLayoutRoot(ctx context.Context,
layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category))
} }
if !matchedLibrary && !req.DryRun { if !matchedLibrary && !req.DryRun {
o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category) if targetLibrary, ok := o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category); ok {
targetLibraryID = targetLibrary.ID
}
} }
return layoutRoot, targetLibraryID return layoutRoot, targetLibraryID
} }
+4 -2
View File
@@ -41,7 +41,7 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
if _, ok := ParseCloudLibraryMount(lib.Path); ok { if _, ok := ParseCloudLibraryMount(lib.Path); ok {
return organizeMediaRequest{}, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud") return organizeMediaRequest{}, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
} }
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
if _, ok := ParseCloudLibraryMount(baseRoot); ok { if _, ok := ParseCloudLibraryMount(baseRoot); ok {
return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
} }
@@ -101,7 +101,9 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re
root = categoryRoot(root, category) root = categoryRoot(root, category)
} }
if !matchedLibrary && !req.dryRun { if !matchedLibrary && !req.dryRun {
o.ensureOrganizeLibraryForRoot(ctx, root, mediaType, category) if targetLibrary, ok := o.ensureOrganizeLibraryForRoot(ctx, root, mediaType, category); ok {
targetLibraryID = targetLibrary.ID
}
} }
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{ target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
Root: root, Root: root,
+8
View File
@@ -160,6 +160,14 @@ func redirectOrganizeStagingRoot(root string) string {
return cleaned return cleaned
} }
func normalizeOrganizeDestinationRoot(root string) string {
cleaned := redirectOrganizeStagingRoot(root)
if collectionRoot := organizeMediaCollectionRoot(cleaned); collectionRoot != "" {
return collectionRoot
}
return cleaned
}
func pathAlreadyEndsWith(path, suffix string) bool { func pathAlreadyEndsWith(path, suffix string) bool {
base := strings.TrimSpace(filepath.Base(filepath.Clean(path))) base := strings.TrimSpace(filepath.Base(filepath.Clean(path)))
return strings.EqualFold(base, suffix) return strings.EqualFold(base, suffix)
@@ -111,10 +111,17 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
return false, nil return false, nil
} }
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, &lib, "")) baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, &lib, ""))
targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category) targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category)
if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" { if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" {
return false, nil targetRoot := categoryRoot(o.organizeRoot(baseRoot, mediaType, category), category)
if dryRun {
targetLibrary = model.Library{Path: targetRoot}
} else if created, ok := o.ensureOrganizeLibraryForRoot(ctx, targetRoot, mediaType, category); ok {
targetLibrary = created
} else {
return false, nil
}
} }
if strings.EqualFold(targetLibrary.ID, lib.ID) && pathWithin(media.Path, targetLibrary.Path) { if strings.EqualFold(targetLibrary.ID, lib.ID) && pathWithin(media.Path, targetLibrary.Path) {
return false, nil return false, nil
@@ -297,6 +297,64 @@ func TestReclassifyMisclassifiedMediaMovesScannedAnimeToPhysicalAnimeLibrary(t *
} }
} }
func TestReclassifyMisclassifiedMediaCreatesMissingTargetCategoryLibrary(t *testing.T) {
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
root := t.TempDir()
dest := filepath.Join(root, "media")
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
t.Fatal(err)
}
wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01.mkv")
writeOrgFile(t, wrongPath, "episode")
if err := repos.DB.Create(&model.Media{
LibraryID: euusLib.ID,
Title: "镖人",
OriginalName: "Blades Of The Guardians",
Path: wrongPath,
SeasonNum: 2,
EpisodeNum: 1,
TMDbID: 107463,
Languages: "zh",
Countries: "CN",
Genres: "动画,动作冒险",
ScrapeStatus: "matched",
}).Error; err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{})
if err != nil {
t.Fatalf("reclassify media: %v", err)
}
targetRoot := filepath.Join(dest, "动漫", "国漫")
want := filepath.Join(targetRoot, "镖人", "Season 02", "镖人 - S02E01.mkv")
if res.Reclassified != 1 {
t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("created category target missing at %q: %v", want, err)
}
var created model.Library
if err := repos.DB.Where("path = ?", targetRoot).First(&created).Error; err != nil {
t.Fatalf("missing auto-created anime library: %v", err)
}
var got model.Media
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
t.Fatal(err)
}
if got.LibraryID != created.ID {
t.Fatalf("library_id = %q, want auto-created library %q", got.LibraryID, created.ID)
}
}
func TestReclassifyMisclassifiedMediaMovesWesternAnimationToWesternAnimeLibrary(t *testing.T) { func TestReclassifyMisclassifiedMediaMovesWesternAnimationToWesternAnimeLibrary(t *testing.T) {
repos := newOrganizerTestRepo(t) repos := newOrganizerTestRepo(t)
cfg := &config.Config{} cfg := &config.Config{}
+67
View File
@@ -207,6 +207,73 @@ func TestOrganizeMediaDoesNotRepeatCategoryWhenLibraryIsCategoryRoot(t *testing.
} }
} }
func TestOrganizeMediaTreatsCategoryLibraryAsCollectionRoot(t *testing.T) {
root := t.TempDir()
dest := filepath.Join(root, "media")
libraryRoot := filepath.Join(dest, "电视剧", "欧美剧")
sourceDir := filepath.Join(root, "downloads")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(sourceDir, "Some Show S01E02.mkv")
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "欧美剧", Path: libraryRoot, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Some Show",
Path: source,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 2,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := organizer.OrganizeMediaWithOptions(t.Context(), media.ID, OrganizeOptions{
MediaType: "tv",
MediaCategory: "国产剧",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatal(err)
}
targetRoot := filepath.Join(dest, "电视剧", "国产剧")
want := filepath.Join(targetRoot, "Some Show", "Season 01", "Some Show - S01E02.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file missing: %v", err)
}
nested := filepath.Join(libraryRoot, "国产剧", "Some Show", "Season 01", "Some Show - S01E02.mkv")
if _, err := os.Stat(nested); !os.IsNotExist(err) {
t.Fatalf("must not nest corrected category under current library, stat err=%v", err)
}
var created model.Library
if err := db.Where("path = ?", targetRoot).First(&created).Error; err != nil {
t.Fatalf("corrected category library should be auto-created: %v", err)
}
var refreshed model.Media
if err := db.First(&refreshed, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if refreshed.LibraryID != created.ID {
t.Fatalf("library_id = %q, want auto-created library %q", refreshed.LibraryID, created.ID)
}
}
func TestOrganizeLibrarySkipsFilesAlreadyInsideLibrary(t *testing.T) { func TestOrganizeLibrarySkipsFilesAlreadyInsideLibrary(t *testing.T) {
root := t.TempDir() root := t.TempDir()
libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧") libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧")