410 lines
14 KiB
Go
410 lines
14 KiB
Go
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
|
|
}
|