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 = patDanglingSE.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...) }