package naming import ( "path" "path/filepath" "regexp" "strconv" "strings" "time" ) var ( // folderTagRe matches bracketed or braced provider-ID tags like // [tmdbid-27205], {tvdb-81189}, [imdbid-tt1375666], etc. folderTagRe = regexp.MustCompile(`\s*[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-[\w]+[}\]]`) // titleYearRe matches "Title (Year)" with optional trailing content. titleYearRe = regexp.MustCompile(`^(.+?)\s*\((\d{4})\)`) // seasonEpisodeRe matches S01E01 or s01e05 patterns in filenames. seasonEpisodeRe = regexp.MustCompile(`(?i)[Ss](\d{1,4})[Ee](\d{1,3})`) // airDateRe matches daily/by-date episode names using Jellyfin-style // separators: yyyy-MM-dd, yyyy.MM.dd, yyyy_MM_dd, or yyyy MM dd. airDateRe = regexp.MustCompile(`(?:^|[^0-9])((?:19|20)\d{2})[-._ ]([01]\d)[-._ ]([0-3]\d)(?:[^0-9]|$)`) // seasonDirRe matches "Season XX" directory names, optionally followed by // trailing text (e.g. "Season 01 - Arc 01 - Romance Dawn"). The season // number is captured in group 1. seasonDirRe = regexp.MustCompile(`(?i)^Season\s+(\d{1,4})(?:\s.*)?$`) // numericSeasonDirRe matches numeric-only season directories like "01". numericSeasonDirRe = regexp.MustCompile(`^\d{1,4}$`) // specialsDirRe matches common specials/extras folders. specialsDirRe = regexp.MustCompile(`(?i)^(?:specials?|extras?)$`) ) // ResolvePathContext classifies a media path using both naming heuristics and // the declared library type. libraryType accepts values like "movies", // "movie", "series", "tv", "show", or "mixed". func ResolvePathContext(filePath string, libraryType string) *PathContext { normalized := filepath.ToSlash(filePath) if normalized == "" { return &PathContext{} } baseName := path.Base(normalized) nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) parts := strings.Split(normalized, "/") parentDir := path.Dir(normalized) parentBase := path.Base(parentDir) normalizedLibraryType := normalizeLibraryType(libraryType) ctx := &PathContext{} parsedSeason := 0 parsedEpisode := 0 parsedAirDate := "" if m := seasonEpisodeRe.FindStringSubmatch(nameNoExt); m != nil { parsedSeason, _ = strconv.Atoi(m[1]) parsedEpisode, _ = strconv.Atoi(m[2]) ctx.HasEpisodePattern = true } if airDate, ok := parseAirDate(nameNoExt); ok { parsedAirDate = airDate } allowNumericSeasonDirs := ctx.HasEpisodePattern || normalizedLibraryType == "series" ctx.HasSeasonStructure, _ = detectSeasonStructure(parts[:max(len(parts)-1, 0)], allowNumericSeasonDirs) ctx.HasMovieFolderEvidence = detectMovieFolderEvidence(parentBase, nameNoExt, ctx.HasSeasonStructure) switch normalizedLibraryType { case "movie": ctx.Type = "movie" case "series": ctx.Type = "series" default: switch { case ctx.HasSeasonStructure: ctx.Type = "series" case ctx.HasMovieFolderEvidence: ctx.Type = "movie" case ctx.HasEpisodePattern: ctx.Type = "series" default: ctx.Type = "movie" } } if ctx.Type == "series" { if parsedAirDate != "" { ctx.AirDate = parsedAirDate ctx.HasAirDatePattern = true } if root, ok := deriveSeriesRoot(normalized, ctx.HasEpisodePattern, normalizedLibraryType == "series"); ok { ctx.RootPath = root.RootPath ctx.Title, ctx.Year = parseTitleYearCandidate(root.FolderName) } else if parentDir != "." && parentDir != "/" && parentDir != "" { ctx.RootPath = parentDir ctx.Title, ctx.Year = parseTitleYearCandidate(parentBase) } if ctx.HasEpisodePattern { ctx.SeasonNum = parsedSeason ctx.EpisodeNum = parsedEpisode } else if seasonNum, ok := firstSeasonNumber(parts[:max(len(parts)-1, 0)], allowNumericSeasonDirs); ok { ctx.SeasonNum = seasonNum } return ctx } if root, ok := deriveMovieRoot(normalized); ok { ctx.RootPath = root.RootPath } ctx.Title, ctx.Year = extractMovieTitleYear(normalized) return ctx } // ParseFilename extracts metadata hints from a media file path. // folderType accepts either item-style values ("movie"/"series") or // library-style values ("movies"/"mixed"/"tv"). func ParseFilename(filePath string, folderType string) *FilenameHints { ctx := ResolvePathContext(filePath, folderType) if ctx == nil { return &FilenameHints{} } return &FilenameHints{ Title: ctx.Title, Year: ctx.Year, Type: ctx.Type, SeasonNum: ctx.SeasonNum, EpisodeNum: ctx.EpisodeNum, AirDate: ctx.AirDate, } } // SeriesRoot describes a recognized show root folder for episodic content. type SeriesRoot struct { RootPath string FolderName string } // DetectSeriesRoot derives the show root from a file path when the layout // clearly looks like episodic TV content in the given library context. func DetectSeriesRoot(filePath string, libraryType string) (*SeriesRoot, bool) { ctx := ResolvePathContext(filePath, libraryType) if ctx == nil || ctx.Type != "series" || ctx.RootPath == "" { return nil, false } return &SeriesRoot{ RootPath: ctx.RootPath, FolderName: path.Base(ctx.RootPath), }, true } // stripFolderTags removes bracketed provider-ID tags (e.g. [tmdbid-27205], // {tvdb-81189}) from a folder or file name so they don't leak into titles. func stripFolderTags(name string) string { return strings.TrimSpace(folderTagRe.ReplaceAllString(name, "")) } func isSeasonDirSegment(segment string, allowNumeric bool) bool { if seasonDirRe.MatchString(segment) { return true } return allowNumeric && numericSeasonDirRe.MatchString(segment) } // DetectCanonicalRoot returns the canonical content root for a media file // path using the given library context. func DetectCanonicalRoot(filePath string, libraryType string) (*CanonicalRoot, bool) { ctx := ResolvePathContext(filePath, libraryType) if ctx == nil || ctx.RootPath == "" || ctx.Type == "" { return nil, false } return &CanonicalRoot{ RootPath: ctx.RootPath, Type: ctx.Type, }, true } func normalizeLibraryType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "movie", "movies": return "movie" case "series", "tv", "show", "tvshows": return "series" default: return "" } } func parseAirDate(name string) (string, bool) { m := airDateRe.FindStringSubmatch(name) if m == nil { return "", false } candidate := m[1] + "-" + m[2] + "-" + m[3] if _, err := time.Parse("2006-01-02", candidate); err != nil { return "", false } return candidate, true } func detectSeasonStructure(parts []string, allowNumeric bool) (bool, int) { for _, part := range parts { if m := seasonDirRe.FindStringSubmatch(part); m != nil { seasonNum, _ := strconv.Atoi(m[1]) return true, seasonNum } if numericSeasonDirRe.MatchString(part) && allowNumeric { seasonNum, _ := strconv.Atoi(part) return true, seasonNum } if specialsDirRe.MatchString(part) { return true, 0 } } return false, 0 } func firstSeasonNumber(parts []string, allowNumeric bool) (int, bool) { _, seasonNum := detectSeasonStructure(parts, allowNumeric) if seasonNum == 0 { for _, part := range parts { if specialsDirRe.MatchString(part) { return 0, true } } } for _, part := range parts { if m := seasonDirRe.FindStringSubmatch(part); m != nil { seasonNum, _ := strconv.Atoi(m[1]) return seasonNum, true } if numericSeasonDirRe.MatchString(part) && allowNumeric { seasonNum, _ := strconv.Atoi(part) return seasonNum, true } } return 0, false } func detectMovieFolderEvidence(parentBase string, nameNoExt string, hasSeasonStructure bool) bool { if hasSeasonStructure { return false } if hasExplicitFolderIDs(parentBase) { return true } parentComparable := normalizeComparableTitle(parentBase) fileComparable := normalizeComparableTitle(nameNoExt) if parentComparable == "" || fileComparable == "" { return false } parentTitle, parentYear := parseTitleYearCandidate(parentBase) if parentTitle == "" || parentYear == 0 { return false } return comparableTitlesOverlap(fileComparable, parentComparable) } func hasExplicitFolderIDs(name string) bool { return folderIDPattern.MatchString(name) } func parseTitleYearCandidate(name string) (string, int) { candidate := strings.TrimSpace(stripFolderTags(name)) candidate = collapseWhitespace(candidate) if candidate == "" { return "", 0 } if m := titleYearRe.FindStringSubmatch(candidate); m != nil { year, _ := strconv.Atoi(m[2]) return strings.TrimSpace(m[1]), year } return candidate, 0 } func normalizeComparableTitle(name string) string { candidate := stripFolderTags(name) candidate = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(candidate) candidate = collapseWhitespace(strings.TrimSpace(candidate)) title, _ := parseTitleYearCandidate(candidate) if title == "" { title = candidate } return strings.ToLower(collapseWhitespace(strings.TrimSpace(title))) } func collapseWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") } func deriveSeriesRoot(filePath string, hasEpisodePattern bool, forceParent bool) (*SeriesRoot, bool) { parentDir := path.Dir(filePath) for current := parentDir; current != "." && current != "/" && current != ""; current = path.Dir(current) { segment := path.Base(current) if isSeasonDirSegment(segment, hasEpisodePattern || forceParent) || specialsDirRe.MatchString(segment) { rootPath := path.Dir(current) if rootPath == "." || rootPath == "/" || rootPath == "" { return nil, false } return &SeriesRoot{ RootPath: rootPath, FolderName: path.Base(rootPath), }, true } } if hasEpisodePattern || forceParent { if parentDir == "." || parentDir == "/" || parentDir == "" { return nil, false } return &SeriesRoot{ RootPath: parentDir, FolderName: path.Base(parentDir), }, true } return nil, false } func deriveMovieRoot(filePath string) (*CanonicalRoot, bool) { baseName := path.Base(filePath) nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) parentDir := path.Dir(filePath) if parentDir == "." || parentDir == "/" || parentDir == "" { return nil, false } parentBase := path.Base(parentDir) parentComparable := normalizeComparableTitle(parentBase) fileComparable := normalizeComparableTitle(nameNoExt) _, parentYear := parseTitleYearCandidate(parentBase) if hasExplicitFolderIDs(parentBase) || parentYear > 0 || comparableTitlesOverlap(fileComparable, parentComparable) { return &CanonicalRoot{ RootPath: parentDir, Type: "movie", }, true } return &CanonicalRoot{ RootPath: path.Join(parentDir, nameNoExt), Type: "movie", }, true } func extractMovieTitleYear(filePath string) (string, int) { baseName := path.Base(filePath) nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) parentBase := path.Base(path.Dir(filePath)) source := nameNoExt if title, year := parseTitleYearCandidate(parentBase); title != "" && (year > 0 || hasExplicitFolderIDs(parentBase)) { return title, year } return parseTitleYearCandidate(source) } func max(a, b int) int { if a > b { return a } return b } func comparableTitlesOverlap(left string, right string) bool { if left == "" || right == "" { return false } return strings.HasPrefix(left, right) || strings.HasPrefix(right, left) }