package scanner import ( "path/filepath" "regexp" "strings" ) var ( yearSepRe = regexp.MustCompile(`[\.\s_-]((19|20)\d{2})[\.\s_-]`) yearParenRe = regexp.MustCompile(`(?i)^(.+?)\s*\(((?:19|20)\d{2})\)\s*(?:\[.*\])?$`) yearEndRe = regexp.MustCompile(`^(.+?)[\.\s_-]((?:19|20)\d{2})$`) resolutionRe = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K)\b`) sourceRe = regexp.MustCompile(`(?i)\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b`) releaseRe = regexp.MustCompile(`(?i)\b(REMUX|PROPER|REPACK)\b`) videoRe = regexp.MustCompile(`(?i)\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b`) audioRe = regexp.MustCompile(`(?i)\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b`) seriesRe1 = regexp.MustCompile(`(?i)(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})`) seriesRe2 = regexp.MustCompile(`(?i)(.+?)[\.\s_-](\d{1,2})x(\d{1,2})`) seasonDirRe = regexp.MustCompile(`(?i)^(?:season|seizoen)\s*0*(\d{1,2})$|^s(\d{1,2})$`) episodeOnly = regexp.MustCompile(`(?i)(?:^|[\.\s_-])(?:e|ep|episode)[\.\s_-]?0*(\d{1,3})(?:[\.\s_-]|$)`) junkNameRe = regexp.MustCompile(`(?i)\b(sample|trailer|preview|featurette|extras?)\b`) ) func parseMovie(base string) map[string]interface{} { title, year := extractMovieTitleYear(base) if title == "" { return nil } result := map[string]interface{}{"title": title} if year > 0 { result["year"] = year } attachTags(result, base) return result } func parseSeries(base string) map[string]interface{} { for _, re := range []*regexp.Regexp{seriesRe1, seriesRe2} { m := re.FindStringSubmatch(base) if len(m) >= 4 { title := cleanTitle(m[1]) season := atoi(m[2]) episode := atoi(m[3]) if title == "" || season <= 0 || episode <= 0 { continue } result := map[string]interface{}{ "title": title, "season": season, "episode": episode, } attachTags(result, base) return result } } return nil } // parseMovieFromPath prefers Plex-style "Title (Year)/file.mkv" folders. func parseMovieFromPath(path string) map[string]interface{} { base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) if isJunkName(base) { return nil } parent := filepath.Base(filepath.Dir(path)) fromFile := parseMovie(base) fromFolder := parseMovieFolder(parent) if fromFolder == nil && fromFile == nil { return nil } if fromFolder == nil { return fromFile } if fromFile == nil { return fromFolder } // Folder title/year are authoritative for library layout; keep quality tags from file. merged := map[string]interface{}{} for k, v := range fromFile { merged[k] = v } merged["title"] = fromFolder["title"] if y, ok := fromFolder["year"]; ok { merged["year"] = y } return merged } // parseSeriesFromPath supports "Show/Season 01/..." and classic SxxExx filenames. func parseSeriesFromPath(path string) map[string]interface{} { base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) if isJunkName(base) { return nil } dir := filepath.Dir(path) parent := filepath.Base(dir) grandparent := filepath.Base(filepath.Dir(dir)) seasonFromDir := parseSeasonFolder(parent) showFromDir := "" if seasonFromDir > 0 { showFromDir = cleanTitle(stripYearParen(grandparent)) } fromFile := parseSeries(base) if fromFile != nil { if showFromDir != "" { fromFile["title"] = showFromDir } if seasonFromDir > 0 { fromFile["season"] = seasonFromDir } return fromFile } episode := parseEpisodeNumber(base) season := seasonFromDir title := showFromDir // Flat: Show Name/S01E02.mkv (no season folder) if title == "" { title = cleanTitle(stripYearParen(parent)) } if season <= 0 && fromFile == nil { // Try SxxExx-less season in parent already handled; leave 0 } if title == "" || season <= 0 || episode <= 0 { return nil } result := map[string]interface{}{ "title": title, "season": season, "episode": episode, } attachTags(result, base) return result } func parseMovieFolder(name string) map[string]interface{} { name = strings.TrimSpace(name) if name == "" || name == "." || name == string(filepath.Separator) { return nil } // Skip generic root-ish folder names lower := strings.ToLower(name) if lower == "movies" || lower == "films" || lower == "movie" || lower == "film" || lower == "media" || lower == "video" || lower == "videos" { return nil } return parseMovie(name) } func extractMovieTitleYear(base string) (string, int) { if m := yearParenRe.FindStringSubmatch(base); len(m) >= 3 { return cleanTitle(m[1]), atoi(m[2]) } if yearMatch := yearSepRe.FindStringSubmatchIndex(base); len(yearMatch) >= 4 { return cleanTitle(base[:yearMatch[0]]), atoi(base[yearMatch[2]:yearMatch[3]]) } if m := yearEndRe.FindStringSubmatch(base); len(m) >= 3 { return cleanTitle(m[1]), atoi(m[2]) } title := cleanTitle(base) return title, 0 } func parseSeasonFolder(name string) int { m := seasonDirRe.FindStringSubmatch(strings.TrimSpace(name)) if len(m) == 0 { return 0 } if m[1] != "" { return atoi(m[1]) } return atoi(m[2]) } func parseEpisodeNumber(base string) int { if m := episodeOnly.FindStringSubmatch(base); len(m) >= 2 { return atoi(m[1]) } // Bare leading number: "01 - Pilot" / "1. Pilot" re := regexp.MustCompile(`^0*(\d{1,3})(?:[\.\s_-]|$)`) if m := re.FindStringSubmatch(base); len(m) >= 2 { n := atoi(m[1]) if n > 0 && n < 1000 { return n } } return 0 } func stripYearParen(s string) string { if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 { return m[1] } return s } func attachTags(result map[string]interface{}, base string) { if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 { result["resolution"] = m[1] } if m := sourceRe.FindStringSubmatch(base); len(m) > 1 { result["source"] = m[1] } if m := releaseRe.FindStringSubmatch(base); len(m) > 1 { result["releaseType"] = m[1] } if m := videoRe.FindStringSubmatch(base); len(m) > 1 { result["videoCodec"] = m[1] } if m := audioRe.FindStringSubmatch(base); len(m) > 0 { result["audio"] = m[0] } } func isJunkName(s string) bool { return junkNameRe.MatchString(s) } func cleanTitle(s string) string { s = strings.ReplaceAll(s, ".", " ") s = strings.ReplaceAll(s, "_", " ") s = strings.ReplaceAll(s, "-", " ") tagRe := regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|WEB-?DL|WEBRip|HDTV|DVDRip|REMUX|PROPER|REPACK|HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD|TrueHD(\s*Atmos)?|DTS-?HD|DTS|AAC|AC-?3|EAC3|Atmos|HDR10?\+?|Dolby\s*Vision|DV)\b`) s = tagRe.ReplaceAllString(s, " ") return strings.Join(strings.Fields(s), " ") } func atoi(s string) int { n := 0 for _, c := range s { if c < '0' || c > '9' { return 0 } n = n*10 + int(c-'0') } return n }