Files
MeBox/internal/service/episode_parser.go
T
2026-06-14 23:17:52 +08:00

183 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\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
}