From 16c3ed239a3a99910ad79df19fc1983eab03b716 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:54:42 +0800 Subject: [PATCH] fix: normalize organize category roots --- internal/service/organizer.go | 2 +- internal/service/organizer_directory.go | 2 +- ...organizer_directory_classification_test.go | 43 ++++++++++++ .../service/organizer_directory_libraries.go | 27 +++++--- .../service/organizer_directory_source.go | 4 +- internal/service/organizer_media.go | 6 +- internal/service/organizer_paths.go | 8 +++ .../service/organizer_reclassify_scanned.go | 11 ++- internal/service/organizer_reclassify_test.go | 58 ++++++++++++++++ internal/service/organizer_test.go | 67 +++++++++++++++++++ 10 files changed, 213 insertions(+), 15 deletions(-) diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 6aacedf..2c36fca 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -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") } // 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。 - baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) + baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) 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") } diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index bddf685..9a99e7a 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -48,7 +48,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO 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") } - dest := redirectOrganizeStagingRoot(resolveMappedDestinationPath(requestedDest)) + dest := normalizeOrganizeDestinationRoot(resolveMappedDestinationPath(requestedDest)) if dest == "" || dest == "." { return nil, errors.New("destination path required") } diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index cd91ab7..12ac083 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -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(), ¤tLib); 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) { root := t.TempDir() srcRoot := filepath.Join(root, "downloads") diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index 5badfa8..956a971 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -161,24 +161,26 @@ func pathHasDirectChild(path, root, child string) bool { 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 { - return + return model.Library{}, false } root = filepath.Clean(strings.TrimSpace(root)) if root == "" || root == "." { - return + return model.Library{}, false } if _, ok := ParseCloudLibraryMount(root); ok { - return + return model.Library{}, false } libraries, err := o.repo.Library.List(ctx) if err != nil { if o.log != nil { 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 { if !lib.Enabled || strings.TrimSpace(lib.Path) == "" { continue @@ -186,9 +188,17 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo if _, ok := ParseCloudLibraryMount(lib.Path); ok { continue } - if pathWithin(root, lib.Path) { - return + lib.Path = filepath.Clean(lib.Path) + 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) if name == "" { @@ -211,7 +221,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo zap.String("name", lib.Name), zap.Error(err)) } - return + return model.Library{}, false } if o.log != nil { 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("name", lib.Name)) } + return lib, true } func organizeLibraryModelType(mediaType string) string { diff --git a/internal/service/organizer_directory_source.go b/internal/service/organizer_directory_source.go index f76f2aa..0d8b6b1 100644 --- a/internal/service/organizer_directory_source.go +++ b/internal/service/organizer_directory_source.go @@ -210,7 +210,9 @@ func (o *OrganizerService) resolveOrganizeSourceLayoutRoot(ctx context.Context, layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) } 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 } diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index d3bfd91..4cce4dc 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -41,7 +41,7 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi 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") } - baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) + baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) 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") } @@ -101,7 +101,9 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re root = categoryRoot(root, category) } 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{ Root: root, diff --git a/internal/service/organizer_paths.go b/internal/service/organizer_paths.go index b404dd7..1876958 100644 --- a/internal/service/organizer_paths.go +++ b/internal/service/organizer_paths.go @@ -160,6 +160,14 @@ func redirectOrganizeStagingRoot(root string) string { 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 { base := strings.TrimSpace(filepath.Base(filepath.Clean(path))) return strings.EqualFold(base, suffix) diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 38ac643..ccf2c4c 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -111,10 +111,17 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod return false, nil } - baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, &lib, "")) + baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, &lib, "")) targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category) 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) { return false, nil diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go index 56558d2..6335a6d 100644 --- a/internal/service/organizer_reclassify_test.go +++ b/internal/service/organizer_reclassify_test.go @@ -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) { repos := newOrganizerTestRepo(t) cfg := &config.Config{} diff --git a/internal/service/organizer_test.go b/internal/service/organizer_test.go index 641b176..b877d00 100644 --- a/internal/service/organizer_test.go +++ b/internal/service/organizer_test.go @@ -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) { root := t.TempDir() libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧")