mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: normalize organize category roots
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(), ¤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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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", "电视剧", "国产剧")
|
||||
|
||||
Reference in New Issue
Block a user