From b16f7d6663e78be0683050ea18de0d64177afced Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:32:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(organize):=20=E5=85=A5=E5=BA=93=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E5=88=86=E7=B1=BB=E5=AF=B9=E9=BD=90MoviePilot?= =?UTF-8?q?=E7=BB=93=E6=9E=84=20+=20=E6=89=8B=E5=8A=A8=E6=95=B4=E7=90=86?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BD=9C=E4=B8=BA=E4=B8=80=E7=BA=A7=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anime 顶层目录独立为「动漫」(动漫/{国漫,日番,儿童}),不再混入电视剧 - categoryPhysicalRootDir 把二级分类名映射到正确顶层: 动画电影/华语电影/外语电影→电影; 国漫/日番/儿童→动漫; 国产剧/日韩剧/欧美剧/纪录片/综艺/未分类→电视剧; 成人→成人 - 新增 staging 重定向:目标根落在「手动整理」等暂存目录时,上提到 父级媒体根,媒体真正归入分类目录(如 媒体/电影/华语电影/片名), 而非停留在手动整理下或与分类目录并列 - organizeLibraryRootForLayout 跳过 staging 名的库,避免回流暂存目录 - 同步更新陈旧测试期望(电视剧→动漫)+新增 staging 重定向测试 --- internal/service/organize_naming_test.go | 4 +- internal/service/organizer.go | 111 +++++++++++++++++-- internal/service/organizer_directory.go | 52 ++++++++- internal/service/organizer_directory_test.go | 32 +++++- internal/service/organizer_scrape_test.go | 61 +++++++++- internal/service/organizer_target.go | 22 +++- 6 files changed, 263 insertions(+), 19 deletions(-) diff --git a/internal/service/organize_naming_test.go b/internal/service/organize_naming_test.go index 162796e..fd2676d 100644 --- a/internal/service/organize_naming_test.go +++ b/internal/service/organize_naming_test.go @@ -127,7 +127,7 @@ func TestOrganizeDirectoryHonorsConfiguredNamingFormats(t *testing.T) { }); err != nil { t.Fatalf("organize anime: %v", err) } - animeWant := filepath.Join(dest, "电视剧", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv") + animeWant := filepath.Join(dest, "动漫", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv") if _, err := os.Stat(animeWant); err != nil { t.Fatalf("anime naming format not honored, want %q: %v", animeWant, err) } @@ -185,7 +185,7 @@ func TestOrganizeDirectoryUsesSeriesFolderWhenFileTitleIsOnlyReleaseTags(t *test t.Fatalf("organize anime: %v", err) } - want := filepath.Join(dest, "电视剧", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv") + want := filepath.Join(dest, "动漫", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv") if _, err := os.Stat(want); err != nil { t.Fatalf("organize should use series folder title and keep video_format, want %q: %v", want, err) } diff --git a/internal/service/organizer.go b/internal/service/organizer.go index e3b1ad4..354f126 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -123,7 +123,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID if _, ok := ParseCloudLibraryMount(lib.Path); ok { return "", 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 := o.resolveBaseRoot(ctx, lib, opts.DestPath) + baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) if _, ok := ParseCloudLibraryMount(baseRoot); ok { return "", errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") } @@ -333,7 +333,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 := o.resolveBaseRoot(ctx, lib, opts.DestPath) + baseRoot := redirectOrganizeStagingRoot(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") } @@ -371,16 +371,14 @@ func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Lib season, episode := m.SeasonNum, m.EpisodeNum if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedSeason > 0 || parsedEpisode > 0 { - if parsedSeason > 0 { - season = parsedSeason - } + season = parsedSeason if parsedEpisode > 0 { episode = parsedEpisode } } if local, err := ReadLocalMetadata(m.Path, lib.Path, true); err == nil && local != nil { - if local.SeasonNum > 0 { + if local.SeasonNum > 0 || local.EpisodeNum > 0 { season = local.SeasonNum } if local.EpisodeNum > 0 { @@ -390,7 +388,7 @@ func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Lib o.log.Warn("organize read local metadata failed", zap.String("path", m.Path), zap.Error(err)) } - if season <= 0 || episode <= 0 { + if season < 0 || episode <= 0 { return fmt.Errorf("cannot determine season/episode for %s", m.Path) } m.SeasonNum = season @@ -467,7 +465,7 @@ func sanitizeFilename(s string) string { } func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) string { - typeDir := mediaTypeRootDir(mediaType) + typeDir := o.mediaTypeRootDirForCategory(mediaType, category) if typeDir == "" || pathAlreadyEndsWith(libraryPath, typeDir) { return libraryPath } @@ -477,6 +475,61 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) return libraryPath } +func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string { + if root := o.categoryPhysicalRootDir(category); root != "" { + return root + } + return mediaTypeRootDir(mediaType) +} + +func (o *OrganizerService) categoryPhysicalRootDir(category string) string { + key := normalizeOrganizeCategoryKey(category) + if key == "" { + return "" + } + categories := o.categoryMap() + match := func(values ...string) bool { + for _, value := range values { + if key == normalizeOrganizeCategoryKey(value) { + return true + } + } + return false + } + switch { + case match( + categoryName(categories, "cn_anime", "国漫"), + categoryName(categories, "jp_anime", "日番"), + categoryName(categories, "children", "儿童"), + "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "儿童", "少儿", + ): + return "动漫" + case match( + categoryName(categories, "domestic_tv", "国产剧"), + categoryName(categories, "euus_tv", "欧美剧"), + categoryName(categories, "jk_tv", "日韩剧"), + categoryName(categories, "variety", "综艺"), + categoryName(categories, "documentary", "纪录片"), + categoryName(categories, "uncategorized_tv", "未分类"), + "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类", + ): + return "电视剧" + case match( + categoryName(categories, "animation_movie", "动画电影"), + categoryName(categories, "chinese_movie", "华语电影"), + categoryName(categories, "foreign_movie", "外语电影"), + categoryName(categories, "euus_movie", "欧美电影"), + categoryName(categories, "jk_movie", "日韩电影"), + "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影", + ): + return "电影" + case match(categoryName(categories, "adult", "成人"), categoryName(categories, "adult_9kg", "9KG"), categoryName(categories, "adult_jav", "番号"), "成人", "9kg", "番号", "jav"): + return "成人" + default: + return "" + } +} + func categoryRoot(root, category string) string { if strings.TrimSpace(category) == "" || pathAlreadyEndsWith(root, category) { return root @@ -501,7 +554,9 @@ func mediaTypeRootDir(mediaType string) string { switch normalizeMediaType(mediaType, "", "") { case "movie": return "电影" - case "tv", "anime", "variety": + case "anime": + return "动漫" + case "tv", "variety": return "电视剧" case "adult": return "成人" @@ -520,6 +575,44 @@ func isGenericMediaRoot(path string) bool { } } +// organizeStagingFolderNames 列出"手动整理"类暂存目录名。这些目录只是修正错误 +// 入库时的中转工作区,不能作为一级分类目录留存。整理时若目标根落在这类目录 +// 内,应重定向到其父级媒体根,让媒体真正归入 电影/电视剧/动漫/成人 的二级分类 +// 目录中(如 媒体/电影/华语电影/片名),而不是停留在暂存目录下。 +func organizeStagingFolderNames() map[string]struct{} { + return map[string]struct{}{ + "手动整理": {}, "手动整理入库": {}, "待整理": {}, "待分类": {}, + "manual": {}, "manual_organize": {}, "manualorganize": {}, "staging": {}, "inbox": {}, + } +} + +func isOrganizeStagingDir(path string) bool { + base := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Clean(path)))) + if base == "" { + return false + } + _, ok := organizeStagingFolderNames()[base] + return ok +} + +// redirectOrganizeStagingRoot 把"手动整理"类暂存目录的目标根重定向到父级媒体根。 +// 连续多层暂存目录(如 .../media/手动整理/待整理)会被逐层上提到真正的媒体根, +// 随后分类逻辑会补上 电影/电视剧/动漫/成人 等顶层与二级分类。 +func redirectOrganizeStagingRoot(root string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return root + } + for isOrganizeStagingDir(cleaned) { + parent := filepath.Dir(cleaned) + if parent == cleaned || parent == "." || parent == string(filepath.Separator) { + break + } + cleaned = parent + } + 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_directory.go b/internal/service/organizer_directory.go index c869744..05ba0d2 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -127,7 +127,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 := resolveMappedDestinationPath(requestedDest) + dest := redirectOrganizeStagingRoot(resolveMappedDestinationPath(requestedDest)) if dest == "" || dest == "." { return nil, errors.New("destination path required") } @@ -537,6 +537,9 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour } else if err != nil && o.log != nil { o.log.Debug("organize read local metadata before rename failed", zap.String("path", src), zap.Error(err)) } + if match := o.lookupOrganizeAdultMetadata(ctx, src, mediaType, title); match != nil { + return match + } if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() { return nil } @@ -605,6 +608,49 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour return nil } +func (o *OrganizerService) lookupOrganizeAdultMetadata(ctx context.Context, src, mediaType, title string) *Match { + if o == nil || o.scraper == nil || o.scraper.adult == nil || !o.scraper.adult.Enabled() { + return nil + } + isAdult := normalizeOrganizeMediaType(mediaType) == "adult" + candidates := []string{src, filepath.Base(src), title} + outCodes := make([]string, 0, len(candidates)) + seen := map[string]struct{}{} + for _, candidate := range candidates { + code := normalizeAdultCode(candidate) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + outCodes = append(outCodes, code) + } + if !isAdult && len(outCodes) == 0 { + return nil + } + for _, code := range outCodes { + match, err := o.scraper.adult.Search(ctx, code) + if err != nil { + if o.log != nil { + o.log.Debug("organize adult metadata search failed", zap.String("source", src), zap.String("code", code), zap.Error(err)) + } + continue + } + if match != nil && strings.TrimSpace(match.Title) != "" { + if o.log != nil { + o.log.Info("organize adult metadata matched before rename", + zap.String("source", src), + zap.String("code", code), + zap.String("title", match.Title)) + } + return match + } + } + return nil +} + func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool { if match == nil || strings.TrimSpace(match.Title) == "" { return false @@ -834,6 +880,10 @@ func (o *OrganizerService) organizeLibraryRootForLayout(ctx context.Context, des if _, ok := ParseCloudLibraryMount(lib.Path); ok { continue } + if isOrganizeStagingDir(lib.Path) { + // "手动整理"等暂存库不作为入库目标,避免把媒体留在暂存目录里。 + continue + } if destRoot != "" && destRoot != "." && !pathWithin(lib.Path, destRoot) && !pathWithin(destRoot, lib.Path) { continue } diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index 2238419..86a28d0 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -205,6 +205,36 @@ func TestOrganizeDirectoryHonorsManualMediaType(t *testing.T) { } } +func TestOrganizeDirectoryRedirectsManualStagingDest(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + // 目标指向"手动整理"暂存目录:媒体应归入父级媒体根的分类目录, + // 而不是停留在 手动整理/ 下,也不是作为分类目录的兄弟目录。 + dest := filepath.Join(root, "media", "手动整理") + writeOrgFile(t, filepath.Join(src, "Some Movie 2024 1080p.mkv"), "movie") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: filepath.Join(src, "Some Movie 2024 1080p.mkv"), + DestPath: dest, + MediaType: "movie", + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize into staging dest: %v", err) + } + if res.Organized != 1 { + t.Fatalf("result = %+v, want organized=1", res) + } + want := filepath.Join(root, "media", "电影", "Some Movie (2024)", "Some Movie (2024).mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected organized movie at %q: %v; items=%+v", want, err, res.Items) + } + if _, err := os.Stat(filepath.Join(dest, "电影")); err == nil { + t.Fatalf("media must not be nested under the 手动整理 staging dir") + } +} + func TestOrganizeDirectoryHonorsAdultMediaTypeRoot(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads", "ABP-123.mkv") @@ -730,7 +760,7 @@ func TestOrganizeDirectorySmartClassifiesWithLocalNFO(t *testing.T) { if res.Organized != 1 { t.Fatalf("organized = %d, want 1", res.Organized) } - want := filepath.Join(dest, "电视剧", "日番", "Some Show", "Season 01", "Some Show - S01E01.mkv") + want := filepath.Join(dest, "动漫", "日番", "Some Show", "Season 01", "Some Show - S01E01.mkv") if _, err := os.Stat(want); err != nil { t.Fatalf("expected NFO classified episode at %q: %v", want, err) } diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index 1be849f..db4c91c 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -106,6 +106,63 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) { } } +func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`SSIS-001 整理候选`)) + case "/v/ssis001": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`