fix(organize): 入库二级分类对齐MoviePilot结构 + 手动整理不再作为一级目录

- anime 顶层目录独立为「动漫」(动漫/{国漫,日番,儿童}),不再混入电视剧
- categoryPhysicalRootDir 把二级分类名映射到正确顶层:
  动画电影/华语电影/外语电影→电影; 国漫/日番/儿童→动漫;
  国产剧/日韩剧/欧美剧/纪录片/综艺/未分类→电视剧; 成人→成人
- 新增 staging 重定向:目标根落在「手动整理」等暂存目录时,上提到
  父级媒体根,媒体真正归入分类目录(如 媒体/电影/华语电影/片名),
  而非停留在手动整理下或与分类目录并列
- organizeLibraryRootForLayout 跳过 staging 名的库,避免回流暂存目录
- 同步更新陈旧测试期望(电视剧→动漫)+新增 staging 重定向测试
This commit is contained in:
ShukeBta
2026-06-18 19:32:59 +00:00
parent 0cec6606c1
commit b16f7d6663
6 changed files with 263 additions and 19 deletions
+2 -2
View File
@@ -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)
}
+102 -9
View File
@@ -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)
+51 -1
View File
@@ -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
}
+31 -1
View File
@@ -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)
}
+59 -2
View File
@@ -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(`<a class="box" href="/v/ssis001"><strong>SSIS-001 整理候选</strong></a>`))
case "/v/ssis001":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<h2 class="title"><strong>SSIS-001 整理成人标题</strong></h2><div>日期 2024-01-02</div>`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
if err := repos.DB.AutoMigrate(&model.APIConfig{}); err != nil {
t.Fatal(err)
}
apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
baseURL := upstream.URL
if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
t.Fatal(err)
}
log := zap.NewNop()
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "SSIS-001.1080p.mkv")
writeOrgFile(t, sourceFile, "adult")
organizer := NewOrganizerService(&config.Config{}, log, repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "adult",
DryRun: true,
})
if err != nil {
t.Fatalf("organize adult directory: %v", err)
}
if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized preview", res)
}
if res.Items[0].Title != "整理成人标题" || res.Items[0].MediaType != "adult" {
t.Fatalf("adult organize item = %+v", res.Items[0])
}
wantSuffix := filepath.Join("成人", "整理成人标题 (2024)", "整理成人标题 (2024).mkv")
if !strings.Contains(res.Items[0].Target, wantSuffix) {
t.Fatalf("adult target = %q, want suffix %q", res.Items[0].Target, wantSuffix)
}
}
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -210,7 +267,7 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T)
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
want := filepath.Join(dest, "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
@@ -517,7 +574,7 @@ func TestOrganizeDirectoryUsesBangumiForAnimeRename(t *testing.T) {
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "葬送的芙莉莲", "Season 01", "葬送的芙莉莲 - S01E01.mkv")
want := filepath.Join(dest, "动漫", "葬送的芙莉莲", "Season 01", "葬送的芙莉莲 - S01E01.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
+18 -4
View File
@@ -45,7 +45,7 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ
episodeTag := ""
if in.Series {
season := in.Season
if season <= 0 {
if season < 0 {
season = 1
}
episode := in.Episode
@@ -110,7 +110,7 @@ func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType s
func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string {
if series {
if season <= 0 {
if season < 0 {
season = 1
}
if episode <= 0 {
@@ -196,7 +196,7 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool {
case "year":
return data.Year > 0
case "season":
return data.Season > 0
return data.Season >= 0 && data.Episode > 0
case "episode", "ep":
return data.Episode > 0
case "ext", "extension":
@@ -228,7 +228,7 @@ func organizeTemplateValue(name, format string, data organizeNamingData, fallbac
}
return strconv.Itoa(data.Year)
case "season":
return formatOrganizeNumber(data.Season, format, 1)
return formatOrganizeNumberAllowZero(data.Season, format, 1)
case "episode", "ep":
return formatOrganizeNumber(data.Episode, format, 1)
case "ext", "extension":
@@ -301,6 +301,20 @@ func formatOrganizeNumber(value int, format string, fallback int) string {
return strconv.Itoa(value)
}
func formatOrganizeNumberAllowZero(value int, format string, fallback int) string {
if value < 0 {
value = fallback
}
format = strings.TrimSpace(strings.TrimSuffix(format, "d"))
if strings.HasPrefix(format, "0") {
width, err := strconv.Atoi(strings.TrimPrefix(format, "0"))
if err == nil && width > 0 {
return fmt.Sprintf("%0*d", width, value)
}
}
return strconv.Itoa(value)
}
func cleanOrganizeRelativePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {