From ab9e5d6a8fec6dbfa57f6d9b3b3709cf8ce5aba9 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Mon, 8 Jun 2026 10:06:55 +0800 Subject: [PATCH] fix: classify download organize output --- internal/service/organizer_directory.go | 105 ++++++++++++++++++- internal/service/organizer_directory_test.go | 30 ++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index a702a0b..96fff28 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -99,7 +99,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO if _, ok := videoExtensions[ext]; !ok { return nil } - if err := o.organizeSourceFile(ctx, path, dest, mode, res); err != nil { + if err := o.organizeSourceFile(ctx, path, source, dest, mode, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error())) } return nil @@ -118,9 +118,14 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO return res, nil } +type organizeDirectoryLayout struct { + MediaType string + Category string +} + // organizeSourceFile organizes a single video file from the source directory // into destRoot, applying dedup + 洗版. -func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, destRoot string, mode TransferMode, res *OrganizeResult) error { +func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, res *OrganizeResult) error { ext := filepath.Ext(src) title, year := CleanQuery(src) if title == "" { @@ -134,12 +139,18 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, destRoot title = "Unknown" } season, episode := ParseEpisode(src) + layout := o.inferOrganizeDirectoryLayout(src, sourceRoot) + layoutRoot := destRoot + if layout.Category != "" { + root := o.organizeRoot(destRoot, layout.MediaType, layout.Category) + layoutRoot = categoryRoot(root, sanitizeFilename(layout.Category)) + } var destDir, dst, episodeTag string if season > 0 || episode > 0 { // TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext episodeTag = fmt.Sprintf("S%02dE%02d", season, episode) - destDir = filepath.Join(destRoot, title, fmt.Sprintf("Season %02d", season)) + destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season)) dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext)) } else { // 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext @@ -147,7 +158,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, destRoot if year > 0 { folder = fmt.Sprintf("%s (%d)", title, year) } - destDir = filepath.Join(destRoot, folder) + destDir = filepath.Join(layoutRoot, folder) dst = filepath.Join(destDir, folder+ext) } @@ -208,6 +219,92 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, destRoot return nil } +func (o *OrganizerService) inferOrganizeDirectoryLayout(src, sourceRoot string) organizeDirectoryLayout { + for _, name := range organizeDirectoryCategoryCandidates(src, sourceRoot) { + if mediaType, category := o.mediaTypeForDirectoryCategory(name); mediaType != "" && category != "" { + return organizeDirectoryLayout{MediaType: mediaType, Category: category} + } + } + return organizeDirectoryLayout{} +} + +func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string { + var out []string + seen := map[string]struct{}{} + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" || value == "." || value == string(os.PathSeparator) { + return + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, value) + } + + cleanSourceRoot := filepath.Clean(sourceRoot) + add(filepath.Base(cleanSourceRoot)) + rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src)) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return out + } + dir := filepath.Dir(rel) + if dir == "." { + return out + } + for _, part := range strings.Split(dir, string(os.PathSeparator)) { + add(part) + } + return out +} + +func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + return "", "" + } + if hit, ok := o.directoryCategoryTypes()[key]; ok { + return hit.MediaType, hit.Category + } + return "", "" +} + +func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectoryLayout { + categories := o.categoryMap() + out := map[string]organizeDirectoryLayout{} + add := func(category, mediaType string) { + category = strings.TrimSpace(category) + if category == "" { + return + } + out[strings.ToLower(category)] = organizeDirectoryLayout{ + MediaType: mediaType, + Category: category, + } + } + addConfigured := func(key, fallback, mediaType string) { + add(fallback, mediaType) + add(categoryName(categories, key, fallback), mediaType) + } + addConfigured("animation_movie", "动画电影", "movie") + addConfigured("chinese_movie", "华语电影", "movie") + addConfigured("jk_movie", "日韩电影", "movie") + addConfigured("euus_movie", "欧美电影", "movie") + addConfigured("foreign_movie", "外语电影", "movie") + addConfigured("domestic_tv", "国产剧", "tv") + addConfigured("euus_tv", "欧美剧", "tv") + addConfigured("jk_tv", "日韩剧", "tv") + addConfigured("cn_anime", "国漫", "anime") + addConfigured("jp_anime", "日番", "anime") + addConfigured("variety", "综艺", "variety") + addConfigured("documentary", "纪录片", "tv") + addConfigured("children", "儿童", "tv") + addConfigured("uncategorized_tv", "未分类", "tv") + return out +} + // existingVersionPaths returns existing destination files that represent the // same media, combining two strategies and de-duplicating by path: // diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index 9b06bed..f3f315b 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -209,3 +209,33 @@ func TestOrganizeDirectoryTVEpisodeDedup(t *testing.T) { t.Fatalf("expected E02 organized at %q: %v", e02, err) } } + +func TestOrganizeDirectoryUsesDownloadCategoryLayout(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "国产剧", "狂飙.S01E01.2023.1080p.WEB-DL.mkv"), "kuangbiao-e01") + writeOrgFile(t, filepath.Join(src, "华语电影", "流浪地球2.2023.2160p.WEB-DL.H265.mkv"), "wandering-earth-2") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 2 || res.Replaced != 0 || res.Skipped != 0 { + t.Fatalf("expected organized=2 replaced=0 skipped=0, got %+v", res) + } + + tv := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") + if _, err := os.Stat(tv); err != nil { + t.Fatalf("expected TV episode organized at %q: %v", tv, err) + } + movie := filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv") + if _, err := os.Stat(movie); err != nil { + t.Fatalf("expected movie organized at %q: %v", movie, err) + } +}