Files
MeBox/internal/service/scraper_query.go
T
2026-09-09 22:03:52 +08:00

220 lines
6.2 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
import (
"context"
"path/filepath"
"regexp"
"strings"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
var (
episodeOnlyQueryRE = regexp.MustCompile(`(?i)^\s*(?:e(?:p(?:isode)?)?\s*\d{1,3}|episode\s*\d{1,3}|第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?)\s*$`)
episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`)
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`)
patTheatricalTitle = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)`)
patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`)
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
)
func theatricalTitleVariants(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if !theatricalNoiseRE.MatchString(raw) {
return []string{raw}
}
stripped := theatricalNoiseRE.ReplaceAllString(raw, " ")
stripped = strings.Join(strings.Fields(stripped), " ")
if stripped != "" && !strings.EqualFold(stripped, raw) {
return []string{stripped, raw}
}
return []string{raw}
}
func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
if m == nil {
return false
}
// Trust an episode marker that is actually present in the path, but do not
// trust persisted season/episode fields here. Older scans could incorrectly
// assign those fields to a theatrical file, which would permanently prevent
// both manual re-scraping and separation from the TV series.
if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 {
return false
}
text := m.Title + " " + pathBaseSlash(m.Path)
return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path)
}
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQuery(raw)
})
}
func scrapeQueryCandidatesWithRecognition(ctx context.Context, repo *repository.Container, m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQueryWithRecognition(ctx, repo, raw)
})
}
func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, clean func(string) (string, int)) []string {
seen := map[string]struct{}{}
var out []string
add := func(raw string) {
for _, variant := range theatricalTitleVariants(raw) {
cleaned, _ := clean(variant)
if cleaned == "" {
cleaned = strings.TrimSpace(variant)
}
for _, candidate := range titleCandidates(cleaned) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
}
key := strings.ToLower(candidate)
if _, ok := seen[key]; ok || candidate == "" {
continue
}
seen[key] = struct{}{}
out = append(out, candidate)
}
}
}
isTheatrical := mediaLooksLikeTheatricalFeature(m)
if isTheatrical {
add(m.Title)
add(m.Path)
if lib != nil {
seriesTitle := seriesFolderTitle(m.Path, lib.Path)
if seriesTitle != "" {
baseName := pathBaseSlash(m.Path)
stem := mediaFileStem(baseName)
if stem == "" {
stem = strings.TrimSuffix(baseName, filepath.Ext(baseName))
}
cleanStem, _ := clean(stem)
if cleanStem == "" {
cleanStem = stem
}
if !strings.Contains(strings.ToLower(cleanStem), strings.ToLower(seriesTitle)) {
add(seriesTitle + " " + cleanStem)
}
}
add(mediaFolderTitle(m.Path, lib.Path))
}
} else {
episodic := mediaIsEpisodic(m, lib)
if lib != nil && episodic {
add(seriesFolderTitle(m.Path, lib.Path))
}
if lib != nil {
add(mediaFolderTitle(m.Path, lib.Path))
}
add(m.Title)
add(m.Path)
}
if len(out) == 0 {
base := pathBaseSlash(m.Path)
out = append(out, strings.TrimSuffix(base, filepath.Ext(base)))
}
return out
}
func titleCandidates(title string) []string {
title = strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
if title == "" {
return nil
}
out := make([]string, 0, 2)
if cjk := cjkTitleOnly(title); cjk != "" {
out = append(out, cjk)
if cjk != title {
return out
}
}
out = append(out, title)
return out
}
func cjkTitleOnly(title string) string {
parts := make([]string, 0, 4)
for _, field := range strings.Fields(title) {
if containsCJK(field) {
parts = append(parts, field)
}
}
return strings.Join(parts, " ")
}
func containsCJK(s string) bool {
for _, r := range s {
switch {
case r >= '\u3400' && r <= '\u4dbf':
return true
case r >= '\u4e00' && r <= '\u9fff':
return true
case r >= '\uf900' && r <= '\ufaff':
return true
}
}
return false
}
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
if m != nil && mediaLooksLikeTheatricalFeature(m) {
return false
}
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
return true
}
if m != nil {
season, episode := ParseEpisode(m.Path)
if season > 0 || episode > 0 {
return true
}
}
return librarySupportsSeasons(lib)
}
func librarySupportsSeasons(lib *model.Library) bool {
if lib == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(lib.Type)) {
case "tv", "anime", "variety", "show", "shows":
return true
default:
return false
}
}
func unsafeAutomaticEpisodeQuery(query string) bool {
query = strings.TrimSpace(query)
if query == "" {
return true
}
if episodeOnlyQueryRE.MatchString(query) || genericEpisodeWordsRE.MatchString(query) {
return true
}
if episodeTitleQueryRE.MatchString(query) {
return true
}
_, episode := ParseEpisode(query)
if episode > 0 && !looksLikeSeriesReleaseTitle(query) {
return true
}
return false
}
func looksLikeSeriesReleaseTitle(query string) bool {
cleaned, _ := CleanQuery(query)
return strings.TrimSpace(cleaned) != "" && episodeReleaseTitleTagRE.MatchString(query)
}