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")
}
// 目的地目录:已位于该根下的文件视为已整理;受 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")
}
+1 -1
View File
@@ -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")
}
@@ -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) {
root := t.TempDir()
srcRoot := filepath.Join(root, "downloads")
@@ -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 {
@@ -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
}
+4 -2
View File
@@ -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,
+8
View File
@@ -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)
@@ -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
@@ -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{}
+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) {
root := t.TempDir()
libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧")