Files
MeBox/internal/service/organizer_target.go
T
ShukeBta b16f7d6663 fix(organize): 入库二级分类对齐MoviePilot结构 + 手动整理不再作为一级目录
- anime 顶层目录独立为「动漫」(动漫/{国漫,日番,儿童}),不再混入电视剧
- categoryPhysicalRootDir 把二级分类名映射到正确顶层:
  动画电影/华语电影/外语电影→电影; 国漫/日番/儿童→动漫;
  国产剧/日韩剧/欧美剧/纪录片/综艺/未分类→电视剧; 成人→成人
- 新增 staging 重定向:目标根落在「手动整理」等暂存目录时,上提到
  父级媒体根,媒体真正归入分类目录(如 媒体/电影/华语电影/片名),
  而非停留在手动整理下或与分类目录并列
- organizeLibraryRootForLayout 跳过 staging 名的库,避免回流暂存目录
- 同步更新陈旧测试期望(电视剧→动漫)+新增 staging 重定向测试
2026-06-18 19:32:59 +00:00

339 lines
8.8 KiB
Go

package service
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type organizeTargetInput struct {
Root string
MediaType string
Category string
Title string
Source string
Ext string
Year int
Season int
Episode int
Series bool
}
type organizeTargetPath struct {
Dir string
Path string
EpisodeTag string
}
func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organizeTargetInput) (organizeTargetPath, error) {
root := filepath.Clean(strings.TrimSpace(in.Root))
if root == "" || root == "." {
return organizeTargetPath{}, fmt.Errorf("organize target root required")
}
title := sanitizeFilename(strings.TrimSpace(in.Title))
if title == "" {
title = "Unknown"
}
ext := strings.TrimSpace(in.Ext)
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
episodeTag := ""
if in.Series {
season := in.Season
if season < 0 {
season = 1
}
episode := in.Episode
if episode <= 0 {
episode = 1
}
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
}
template := strings.TrimSpace(o.organizeNamingFormat(ctx, in.MediaType, in.Series))
var rel string
if template == "" {
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
} else {
rel = renderOrganizeNamingTemplate(template, organizeNamingData{
Title: title,
Year: in.Year,
Season: in.Season,
Episode: in.Episode,
Ext: strings.TrimPrefix(ext, "."),
FileExt: ext,
Category: sanitizeFilename(in.Category),
MediaType: normalizeOrganizeMediaType(in.MediaType),
EpisodeTag: episodeTag,
VideoFormat: extractOrganizeReleaseTag(in.Source),
Part: extractOrganizeReleaseTag(in.Source),
})
rel = cleanOrganizeRelativePath(rel)
if rel == "" {
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
}
if ext != "" && !strings.EqualFold(filepath.Ext(rel), ext) {
rel += ext
}
}
dst := filepath.Join(root, rel)
return organizeTargetPath{
Dir: filepath.Dir(dst),
Path: dst,
EpisodeTag: episodeTag,
}, nil
}
func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType string, series bool) string {
if o == nil || o.repo == nil || o.repo.Setting == nil {
return ""
}
key := "organize.movie_format"
if series {
if normalizeOrganizeMediaType(mediaType) == "anime" {
key = "organize.anime_format"
} else {
key = "organize.tv_format"
}
}
value, err := o.repo.Setting.Get(ctx, key)
if err != nil {
return ""
}
return strings.TrimSpace(value)
}
func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string {
if series {
if season < 0 {
season = 1
}
if episode <= 0 {
episode = 1
}
episodeTag := fmt.Sprintf("S%02dE%02d", season, episode)
return filepath.Join(title, fmt.Sprintf("Season %02d", season), fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
}
folder := title
if year > 0 {
folder = fmt.Sprintf("%s (%d)", title, year)
}
return filepath.Join(folder, folder+ext)
}
type organizeNamingData struct {
Title string
Year int
Season int
Episode int
Ext string
FileExt string
Category string
MediaType string
EpisodeTag string
VideoFormat string
Part string
}
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
var organizeTemplateMustacheRE = regexp.MustCompile(`\{\{\s*([A-Za-z_]+)(?::([^}]+))?\s*\}\}`)
var organizeTemplateIfRE = regexp.MustCompile(`(?s)\{%\s*if\s+([A-Za-z_]+)\s*%\}(.*?)\{%\s*endif\s*%\}`)
func renderOrganizeNamingTemplate(template string, data organizeNamingData) string {
template = renderOrganizeConditionals(template, data)
template = organizeTemplateMustacheRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateMustacheRE.FindStringSubmatch(token)
if len(matches) == 0 {
return token
}
format := ""
if len(matches) > 2 {
format = matches[2]
}
return organizeTemplateValue(matches[1], format, data, token)
})
return organizeTemplateTokenRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateTokenRE.FindStringSubmatch(token)
if len(matches) == 0 {
return token
}
format := ""
if len(matches) > 2 {
format = matches[2]
}
return organizeTemplateValue(matches[1], format, data, token)
})
}
func renderOrganizeConditionals(template string, data organizeNamingData) string {
for {
next := organizeTemplateIfRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateIfRE.FindStringSubmatch(token)
if len(matches) < 3 {
return token
}
if organizeTemplateTruthy(matches[1], data) {
return matches[2]
}
return ""
})
if next == template {
return next
}
template = next
}
}
func organizeTemplateTruthy(name string, data organizeNamingData) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "title", "name":
return data.Title != ""
case "year":
return data.Year > 0
case "season":
return data.Season >= 0 && data.Episode > 0
case "episode", "ep":
return data.Episode > 0
case "ext", "extension":
return data.Ext != ""
case "fileext", "file_ext":
return data.FileExt != ""
case "category":
return data.Category != ""
case "type", "media_type":
return data.MediaType != ""
case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag != ""
case "videoformat", "video_format":
return data.VideoFormat != ""
case "part":
return data.Part != ""
default:
return false
}
}
func organizeTemplateValue(name, format string, data organizeNamingData, fallback string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "title", "name":
return data.Title
case "year":
if data.Year <= 0 {
return ""
}
return strconv.Itoa(data.Year)
case "season":
return formatOrganizeNumberAllowZero(data.Season, format, 1)
case "episode", "ep":
return formatOrganizeNumber(data.Episode, format, 1)
case "ext", "extension":
return data.Ext
case "fileext", "file_ext":
return data.FileExt
case "category":
return data.Category
case "type", "media_type":
return data.MediaType
case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag
case "videoformat", "video_format":
return data.VideoFormat
case "part":
return data.Part
default:
return fallback
}
}
var organizeReleaseTailRE = regexp.MustCompile(`(?i)(?:^|[.\s_\-\[\(])((?:2160p|1080p|720p|480p|4k|uhd|fhd|web[\s._-]*dl|web[\s._-]*rip|bluray|blu[\s._-]*ray|bdrip|hdtv|remux|x26[45]|h[\s._-]*26[45]|hevc|avc|10bit|8bit|hdr10?\+?|dovi|dv|120fps|60fps|dts|ddp?|eac3|aac|flac|truehd|atmos).*)$`)
func extractOrganizeReleaseTag(source string) string {
base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(source)), filepath.Ext(source))
if base == "" {
return ""
}
matches := organizeReleaseTailRE.FindStringSubmatch(base)
if len(matches) < 2 {
return ""
}
value := strings.Trim(matches[1], " ._-[]()")
value = patSEnE.ReplaceAllString(value, " ")
value = patNxE.ReplaceAllString(value, " ")
value = patEP.ReplaceAllString(value, " ")
value = patCN.ReplaceAllString(value, " ")
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ' ', '_', '[', ']', '(', ')', '{', '}':
return true
default:
return false
}
})
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, ".-")
if part != "" {
out = append(out, part)
}
}
return sanitizeFilename(strings.Join(out, "."))
}
func formatOrganizeNumber(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 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 == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
parts := strings.Split(path, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = sanitizeFilename(strings.TrimSpace(part))
part = strings.Trim(part, ". ")
if part == "" || part == "." || part == ".." {
continue
}
out = append(out, part)
}
if len(out) == 0 {
return ""
}
return filepath.Join(out...)
}