package naming import ( "path" "path/filepath" "regexp" "sort" "strconv" "strings" ) var ( plexEditionTagRe = regexp.MustCompile(`(?i)\{edition-([^}]+)\}`) multiEpisodeRangeRe = regexp.MustCompile(`(?i)[Ss](\d{1,4})[Ee](\d{1,3})\s*[-_]\s*[Ee]?(\d{1,3})`) presentationPartRe = regexp.MustCompile(`(?i)(?:^|[.\-_\s])(cd|disc|part|pt)(?:\s*|[._-]?)(\d{1,2})(?:$|[.\-_\s])`) variantReleaseGroupRe = regexp.MustCompile(`(?i)(?:^|[.\s_-])(?:remux|web[ ._-]?dl|webrip|bluray|bdrip|brrip|hdr|dv|2160p|1080p|720p|x264|x265|h\.?264|h\.?265|hevc|av1|aac|ac3|eac3|dts|truehd|atmos).*-([a-z0-9][a-z0-9-]{1,31})$`) variantCleanupSepRe = regexp.MustCompile(`[.\-_]+`) variantWhitespaceRe = regexp.MustCompile(`\s+`) variantQualityTokenRe = regexp.MustCompile(`(?i)\b(?:hdr|dv|sdr|3d|remux|web[ ._-]?dl|webrip|bluray|bdrip|brrip|2160p|1080p|720p|480p|x264|x265|h\.?264|h\.?265|hevc|av1|aac|ac3|eac3|dts|truehd|atmos|proper|repack|multi|dual|vff|german|internal|criterion|custom|hybrid)\b`) ) type editionRule struct { key string display string re *regexp.Regexp tokens []string comparisonSafeSuffix bool } var editionRules = []editionRule{ {key: "extended_director_cut_fan_edit", display: "Extended Directors Cut Fan Edit", re: regexp.MustCompile(`(?i)\bextended\s+directors?\s+cut\s+fan\s+edit\b`), tokens: []string{"extended", "director_cut", "fan_edit"}}, {key: "extended_director_cut", display: "Extended Directors Cut", re: regexp.MustCompile(`(?i)\bextended\s+directors?\s+cut\b`), tokens: []string{"extended", "director_cut"}}, {key: "director_cut", display: "Director's Cut", re: regexp.MustCompile(`(?i)\bdirector'?s?\s+cut\b`), tokens: []string{"director_cut"}}, {key: "editor_cut", display: "Editor's Cut", re: regexp.MustCompile(`(?i)\beditor'?s?\s+cut\b`), tokens: []string{"editor_cut"}}, {key: "final_cut", display: "Final Cut", re: regexp.MustCompile(`(?i)\bfinal\s+cut\b`), tokens: []string{"final_cut"}}, {key: "assembly_cut", display: "Assembly Cut", re: regexp.MustCompile(`(?i)\bassembly\s+cut\b`), tokens: []string{"assembly_cut"}}, {key: "extended", display: "Extended", re: regexp.MustCompile(`(?i)\bextended(?:\s+(?:edition|cut))?\b`), tokens: []string{"extended"}}, {key: "theatrical", display: "Theatrical", re: regexp.MustCompile(`(?i)\btheatrical(?:\s+release)?\b`), tokens: []string{"theatrical"}}, {key: "unrated", display: "Unrated", re: regexp.MustCompile(`(?i)\bunrated\b`), tokens: []string{"unrated"}}, {key: "uncut", display: "Uncut", re: regexp.MustCompile(`(?i)\buncut\b`), tokens: []string{"uncut"}}, {key: "uncensored", display: "Uncensored", re: regexp.MustCompile(`(?i)\buncensored\b`), tokens: []string{"uncensored"}}, {key: "imax_enhanced", display: "IMAX Enhanced", re: regexp.MustCompile(`(?i)\bimax\s+enhanced\b`), tokens: []string{"imax_enhanced"}}, {key: "imax", display: "IMAX", re: regexp.MustCompile(`(?i)\bimax\b`), tokens: []string{"imax"}}, {key: "open_matte", display: "Open Matte", re: regexp.MustCompile(`(?i)\bopen\s+matte\b`), tokens: []string{"open_matte"}}, {key: "despecialized", display: "Despecialized", re: regexp.MustCompile(`(?i)\bdespeciali[sz]ed\b`), tokens: []string{"despecialized"}}, {key: "fan_edit", display: "Fan Edit", re: regexp.MustCompile(`(?i)\bfan\s+edit\b`), tokens: []string{"fan_edit"}}, {key: "special_edition", display: "Special Edition", re: regexp.MustCompile(`(?i)\bspecial\s+edition\b`), tokens: []string{"special_edition"}}, {key: "collectors_edition", display: "Collector's Edition", re: regexp.MustCompile(`(?i)\bcollector'?s\s+edition\b`), tokens: []string{"collectors_edition"}}, {key: "ultimate_edition", display: "Ultimate Edition", re: regexp.MustCompile(`(?i)\bultimate\s+edition\b`), tokens: []string{"ultimate_edition"}}, {key: "remastered", display: "Remastered", re: regexp.MustCompile(`(?i)\bremastered\b`), tokens: []string{"remastered"}}, {key: "restored", display: "Restored", re: regexp.MustCompile(`(?i)\brestored\b`), tokens: []string{"restored"}}, {key: "workprint", display: "Workprint", re: regexp.MustCompile(`(?i)\bworkprint\b`), tokens: []string{"workprint"}}, {key: "hybrid", display: "Hybrid", re: regexp.MustCompile(`(?i)\bhybrid\b`), tokens: []string{"hybrid"}}, {key: "black_and_white", display: "Black and White", re: regexp.MustCompile(`(?i)\bblack\s+(?:and\s+)?white\b`), tokens: []string{"black_and_white"}}, {key: "black_chrome", display: "Black Chrome", re: regexp.MustCompile(`(?i)\bblack\s+chrome\b`), tokens: []string{"black_chrome"}}, {key: "noir", display: "Noir", re: regexp.MustCompile(`(?i)\bnoir\b`), tokens: []string{"noir"}}, {key: "justice_is_gray", display: "Justice Is Gray", re: regexp.MustCompile(`(?i)\bjustice\s+is\s+gr(?:a|e)y\b`), tokens: []string{"justice_is_gray"}, comparisonSafeSuffix: true}, {key: "anniversary_edition", display: "Anniversary Edition", re: regexp.MustCompile(`(?i)\b\d{1,3}(?:st|nd|rd|th)\s+anniversary\s+edition\b`), tokens: []string{"anniversary_edition"}}, } var editionTokenOrder = []string{ "theatrical", "unrated", "uncut", "uncensored", "extended", "director_cut", "editor_cut", "final_cut", "assembly_cut", "special_edition", "collectors_edition", "ultimate_edition", "anniversary_edition", "remastered", "restored", "open_matte", "imax", "imax_enhanced", "fan_edit", "despecialized", "workprint", "black_and_white", "black_chrome", "noir", "justice_is_gray", "hybrid", } // EditionDisplayLabel converts a canonical edition key into a user-facing label. func EditionDisplayLabel(key string) string { normalized := strings.TrimSpace(strings.ToLower(key)) if normalized == "" { return "" } for _, rule := range editionRules { if rule.key == normalized { return rule.display } } parts := strings.Fields(strings.NewReplacer("_", " ", "-", " ").Replace(normalized)) for i, part := range parts { switch part { case "imax": parts[i] = "IMAX" default: parts[i] = strings.ToUpper(part[:1]) + part[1:] } } return strings.Join(parts, " ") } func ParseVariantHints(filePath string, libraryType string) *VariantHints { cleanPath := filepath.ToSlash(filepath.Clean(filePath)) if cleanPath == "" { return &VariantHints{} } hints := &VariantHints{} parts := strings.Split(cleanPath, "/") baseName := path.Base(cleanPath) baseNoExt := strings.TrimSuffix(baseName, path.Ext(baseName)) parentBase := "" if len(parts) > 1 { parentBase = parts[len(parts)-2] } for i := len(parts) - 2; i >= 0; i-- { if match := plexEditionTagRe.FindStringSubmatch(parts[i]); match != nil { hints.EditionRaw = strings.TrimSpace(match[1]) hints.EditionKey = normalizeEditionKey(hints.EditionRaw) hints.EditionSource = "plex_tag_folder" hints.EditionConfidence = floatPtr(1.0) break } } if hints.EditionKey == "" { if match := plexEditionTagRe.FindStringSubmatch(baseNoExt); match != nil { hints.EditionRaw = strings.TrimSpace(match[1]) hints.EditionKey = normalizeEditionKey(hints.EditionRaw) hints.EditionSource = "plex_tag_file" hints.EditionConfidence = floatPtr(1.0) } } if hints.EditionKey == "" { for i := len(parts) - 2; i >= 0; i-- { if raw, key := parseEditionHeuristic(parts[i], "", 0); key != "" { hints.EditionRaw = raw hints.EditionKey = key hints.EditionSource = "heuristic_folder" hints.EditionConfidence = floatPtr(0.7) break } } } if hints.EditionKey == "" { folderTitle, folderYear, _ := parseInferFolderTitleYear(parentBase) if raw, key := parseEditionHeuristic(baseNoExt, folderTitle, folderYear); key != "" { hints.EditionRaw = raw hints.EditionKey = key hints.EditionSource = "heuristic_file" hints.EditionConfidence = floatPtr(0.7) } } if hints.EditionKey == "" { folderTitle, folderYear, _ := parseInferFolderTitleYear(parentBase) if stem := parseInferMovieStem(baseNoExt, folderTitle, folderYear); stem.Title != "" { if _, raw, key, ok := comparisonSafeEditionSuffix(stem.Title); ok { hints.EditionRaw = raw hints.EditionKey = key hints.EditionSource = "heuristic_file" hints.EditionConfidence = floatPtr(0.7) } } } if match := multiEpisodeRangeRe.FindStringSubmatch(baseNoExt); match != nil { start, _ := strconv.Atoi(match[2]) end, _ := strconv.Atoi(match[3]) hints.PresentationKind = "multi_episode" hints.PresentationGroupKey = normalizePresentationGroup(baseNoExt, match[0]) hints.MultiEpisodeStart = start hints.MultiEpisodeEnd = end return hints } if match := presentationPartRe.FindStringSubmatch(" " + baseNoExt + " "); match != nil { partIndex, _ := strconv.Atoi(match[2]) ctx := ResolvePathContext(cleanPath, libraryType) hints.PresentationKind = "multipart_movie" if ctx != nil && ctx.Type == "series" { hints.PresentationKind = "split_episode" } hints.PresentationPartIndex = partIndex hints.PresentationGroupKey = normalizePresentationGroup(baseNoExt, strings.TrimSpace(match[0])) } return hints } func editionHeuristicSurface(baseNoExt string, folderTitle string, folderYear int) string { surface := stripFolderTags(baseNoExt) surface = stripVariantReleaseGroup(surface) surface = plexEditionTagRe.ReplaceAllString(surface, " ") if stem := parseInferMovieStem(surface, folderTitle, folderYear); stem.Title != "" && stem.Year != 0 { surface = stem.Remainder } else if m := titleYearRe.FindStringSubmatch(surface); m != nil { surface = strings.TrimSpace(strings.TrimPrefix(surface, m[0])) } surface = variantQualityTokenRe.ReplaceAllString(surface, " ") return collapseWhitespace(strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(surface)) } func parseEditionHeuristic(surface string, folderTitle string, folderYear int) (string, string) { candidate := editionHeuristicSurface(surface, folderTitle, folderYear) if candidate == "" { return "", "" } lower := strings.ToLower(candidate) switch lower { case "holiday special", "christmas edition": return "", "" } type match struct { start int end int raw string rule editionRule } matches := make([]match, 0, len(editionRules)) tokenSet := make(map[string]struct{}) for _, rule := range editionRules { indices := rule.re.FindAllStringIndex(candidate, -1) for _, idx := range indices { raw := collapseWhitespace(candidate[idx[0]:idx[1]]) if raw == "" { continue } matches = append(matches, match{ start: idx[0], end: idx[1], raw: raw, rule: rule, }) for _, token := range rule.tokens { tokenSet[token] = struct{}{} } } } if len(matches) == 0 { return "", "" } sort.Slice(matches, func(i, j int) bool { if matches[i].start == matches[j].start { return (matches[i].end - matches[i].start) > (matches[j].end - matches[j].start) } return matches[i].start < matches[j].start }) rawParts := make([]string, 0, len(matches)) lastEnd := -1 for _, match := range matches { if match.start < lastEnd { continue } rawParts = append(rawParts, match.raw) lastEnd = match.end } orderedTokens := make([]string, 0, len(tokenSet)) for _, token := range editionTokenOrder { if _, ok := tokenSet[token]; ok { orderedTokens = append(orderedTokens, token) } } if len(orderedTokens) == 0 { return "", "" } return collapseWhitespace(strings.Join(rawParts, " ")), strings.Join(orderedTokens, "_") } func comparisonSafeEditionSuffix(surface string) (prefix string, raw string, key string, ok bool) { cleaned := collapseWhitespace(strings.NewReplacer(".", " ", "_", " ").Replace(strings.TrimSpace(surface))) if cleaned == "" { return "", "", "", false } bestPrefix := "" bestRaw := "" bestKey := "" bestStart := -1 for _, rule := range editionRules { if !rule.comparisonSafeSuffix { continue } indices := rule.re.FindAllStringIndex(cleaned, -1) for _, idx := range indices { if idx[1] != len(cleaned) { continue } candidatePrefix := collapseWhitespace(strings.TrimSpace(cleaned[:idx[0]])) if candidatePrefix == "" { continue } if idx[0] > bestStart { bestStart = idx[0] bestPrefix = candidatePrefix bestRaw = collapseWhitespace(cleaned[idx[0]:idx[1]]) bestKey = rule.key } } } if bestStart < 0 { return "", "", "", false } return bestPrefix, bestRaw, bestKey, true } func StripComparisonSafeEditionSuffix(surface string) string { if prefix, _, _, ok := comparisonSafeEditionSuffix(surface); ok { return prefix } return surface } func normalizeEditionKey(raw string) string { cleaned := strings.TrimSpace(strings.ToLower(raw)) cleaned = strings.NewReplacer("'", "", "\"", "", "&", " and ").Replace(cleaned) cleaned = variantCleanupSepRe.ReplaceAllString(cleaned, " ") cleaned = variantWhitespaceRe.ReplaceAllString(cleaned, " ") cleaned = strings.TrimSpace(cleaned) return strings.ReplaceAll(cleaned, " ", "_") } func stripVariantReleaseGroup(surface string) string { match := variantReleaseGroupRe.FindStringSubmatchIndex(surface) if len(match) < 4 { return surface } return strings.TrimSpace(surface[:match[2]-1]) } func inferEditionTokenKey(token string) string { switch strings.ToLower(strings.TrimSpace(token)) { case "theatrical", "release": return "theatrical" case "unrated": return "unrated" case "uncut": return "uncut" case "uncensored": return "uncensored" case "extended", "cut", "edition": return "extended" case "director", "directors": return "director_cut" case "editor", "editors": return "editor_cut" case "final": return "final_cut" case "assembly": return "assembly_cut" case "imax": return "imax" case "open", "matte": return "open_matte" case "special": return "special_edition" case "collector", "collectors": return "collectors_edition" case "ultimate": return "ultimate_edition" case "anniversary": return "anniversary_edition" case "fan": return "fan_edit" case "despecialized", "despecialised": return "despecialized" case "remastered": return "remastered" case "restored": return "restored" case "workprint": return "workprint" case "hybrid": return "hybrid" case "black", "white": return "black_and_white" case "chrome": return "black_chrome" case "noir": return "noir" default: return "" } } func normalizePresentationGroup(baseNoExt string, matched string) string { group := strings.Replace(baseNoExt, matched, " ", 1) group = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(group) group = variantWhitespaceRe.ReplaceAllString(group, " ") return strings.TrimSpace(group) } func floatPtr(value float64) *float64 { return &value }