mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
299 lines
7.6 KiB
Go
299 lines
7.6 KiB
Go
// 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})`)
|
||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||
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*[集话話期]`)
|
||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\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+)?)?$`)
|
||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
||
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||
// 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 := mediaSidecarBase(path)
|
||
if name == "" {
|
||
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 {
|
||
var found bool
|
||
season, found = seasonFromParents(path)
|
||
if !found {
|
||
season = 1
|
||
}
|
||
episode = mustAtoi(m[1])
|
||
return
|
||
}
|
||
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
||
var found bool
|
||
season, found = seasonFromParents(path)
|
||
if !found {
|
||
season = 1
|
||
}
|
||
episode = mustAtoi(m[1])
|
||
return
|
||
}
|
||
if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||
var found bool
|
||
season, found = seasonFromParents(path)
|
||
if !found {
|
||
season = 1
|
||
}
|
||
episode = mustAtoi(m[1])
|
||
return
|
||
}
|
||
if parentSeason, found := seasonFromParents(path); found {
|
||
if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 {
|
||
season = parentSeason
|
||
episode = mustAtoi(m[1])
|
||
return
|
||
}
|
||
}
|
||
return 0, 0
|
||
}
|
||
|
||
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||
// becomes S00E02. Normal episodes retain their parsed identity.
|
||
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||
season, episode = ParseEpisode(path)
|
||
if episode != 0 {
|
||
return season, episode
|
||
}
|
||
name := mediaSidecarBase(path)
|
||
if name == "" {
|
||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||
}
|
||
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
|
||
specialEpisode := mustAtoi(m[1])
|
||
if specialEpisode > 0 {
|
||
return 0, specialEpisode
|
||
}
|
||
}
|
||
return season, episode
|
||
}
|
||
|
||
type episodeRef struct {
|
||
Season int
|
||
Episode int
|
||
}
|
||
|
||
func episodeRefsFromTitle(path string) []episodeRef {
|
||
name := mediaSidecarBase(path)
|
||
if name == "" {
|
||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||
}
|
||
if refs := parseSEpisodeRange(name); len(refs) > 0 {
|
||
return refs
|
||
}
|
||
if refs := parseCNEpisodeRange(name, path); len(refs) > 0 {
|
||
return refs
|
||
}
|
||
season, episode := ParseEpisode(path)
|
||
if episode <= 0 {
|
||
return nil
|
||
}
|
||
if season <= 0 {
|
||
season = 1
|
||
}
|
||
return []episodeRef{{Season: season, Episode: episode}}
|
||
}
|
||
|
||
func parseSEpisodeRange(name string) []episodeRef {
|
||
m := patSEnERange.FindStringSubmatch(name)
|
||
if len(m) < 5 {
|
||
return nil
|
||
}
|
||
season := mustAtoi(m[1])
|
||
endSeason := season
|
||
if m[3] != "" {
|
||
endSeason = mustAtoi(m[3])
|
||
}
|
||
if season <= 0 || endSeason != season {
|
||
return nil
|
||
}
|
||
start := mustAtoi(m[2])
|
||
end := mustAtoi(m[4])
|
||
return buildEpisodeRefRange(season, start, end)
|
||
}
|
||
|
||
func parseCNEpisodeRange(name, path string) []episodeRef {
|
||
m := patCNRange.FindStringSubmatch(name)
|
||
if len(m) < 3 {
|
||
return nil
|
||
}
|
||
season, found := seasonFromParents(path)
|
||
if !found {
|
||
season = 1
|
||
}
|
||
return buildEpisodeRefRange(season, mustAtoi(m[1]), mustAtoi(m[2]))
|
||
}
|
||
|
||
func buildEpisodeRefRange(season, start, end int) []episodeRef {
|
||
if season <= 0 {
|
||
season = 1
|
||
}
|
||
if start <= 0 || end <= 0 || end < start || end-start > 200 {
|
||
return nil
|
||
}
|
||
refs := make([]episodeRef, 0, end-start+1)
|
||
for episode := start; episode <= end; episode++ {
|
||
refs = append(refs, episodeRef{Season: season, Episode: episode})
|
||
}
|
||
return refs
|
||
}
|
||
|
||
func seasonFromParents(path string) (int, bool) {
|
||
dir := filepath.Dir(path)
|
||
for i := 0; i < 4; i++ {
|
||
base := filepath.Base(dir)
|
||
if base == "." || base == string(filepath.Separator) {
|
||
return 0, false
|
||
}
|
||
if season, ok := seasonFromDir(base); ok {
|
||
return season, true
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
return 0, false
|
||
}
|
||
dir = parent
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func seasonFromDir(name string) (int, bool) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return 0, false
|
||
}
|
||
if patSpecialSeason.MatchString(name) {
|
||
return 0, true
|
||
}
|
||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||
for _, group := range m[1:] {
|
||
if group != "" {
|
||
return mustAtoi(group), true
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
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
|
||
}
|