// Package service — episode parser for TV series. // // Detects season + episode numbers from filenames. Recognised patterns: // // S01E02 / s1e2 // 1x02 / 01x02 // EP02 / E02 // 第2集 / 第02集 // // For bare episode markers such as "EP02", the parser also looks at parent // folders like "Season 02" / "S02" / "第2季" before falling back to season 1. // // When neither a season nor an episode marker is present, returns (0, 0). package service import ( "path/filepath" "regexp" "strconv" "strings" "unicode/utf8" ) var ( patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) ) // ParseEpisode tries to extract (season, episode) from an arbitrary filename. // Returns (0, 0) when nothing recognisable is found. func ParseEpisode(path string) (season, episode int) { name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) if m := patSEnE.FindStringSubmatch(name); len(m) == 3 { season = mustAtoi(m[1]) episode = mustAtoi(m[2]) return } if m := patNxE.FindStringSubmatch(name); len(m) == 3 { season = mustAtoi(m[1]) episode = mustAtoi(m[2]) return } if m := patEP.FindStringSubmatch(name); len(m) >= 2 { season = seasonFromParents(path) if season == 0 { season = 1 } episode = mustAtoi(m[1]) return } if m := patCN.FindStringSubmatch(name); len(m) >= 2 { season = seasonFromParents(path) if season == 0 { season = 1 } episode = mustAtoi(m[1]) return } if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 { season = seasonFromParents(path) if season == 0 { season = 1 } episode = mustAtoi(m[1]) return } if parentSeason := seasonFromParents(path); parentSeason > 0 { if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 { season = parentSeason episode = mustAtoi(m[1]) return } } return 0, 0 } func seasonFromParents(path string) int { dir := filepath.Dir(path) for i := 0; i < 4; i++ { base := filepath.Base(dir) if base == "." || base == string(filepath.Separator) { return 0 } if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 { for _, group := range m[1:] { if group != "" { return mustAtoi(group) } } } parent := filepath.Dir(dir) if parent == dir { return 0 } dir = parent } return 0 } func mustAtoi(s string) int { s = strings.TrimSpace(s) if v, err := strconv.Atoi(s); err == nil { return v } v := parseChineseNumber(s) if v > 0 { return v } return v } func parseChineseNumber(s string) int { s = strings.TrimSpace(s) if s == "" { return 0 } digit := func(r rune) (int, bool) { switch r { case '零', '〇': return 0, true case '一': return 1, true case '二', '两': return 2, true case '三': return 3, true case '四': return 4, true case '五': return 5, true case '六': return 6, true case '七': return 7, true case '八': return 8, true case '九': return 9, true default: return 0, false } } if utf8.RuneCountInString(s) == 1 { if v, ok := digit([]rune(s)[0]); ok { return v } } total := 0 current := 0 for _, r := range s { switch r { case '百': if current == 0 { current = 1 } total += current * 100 current = 0 case '十': if current == 0 { current = 1 } total += current * 10 current = 0 default: v, ok := digit(r) if !ok { return 0 } current = v } } return total + current }