Files
MeBox/internal/service/scraper_candidate_selection.go
T
2026-09-09 17:36:11 +08:00

278 lines
7.6 KiB
Go

package service
import (
"context"
"strings"
"go.uber.org/zap"
)
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
if match := s.lookupAutomaticTMDbPrimary(ctx, kind, query, year); match != nil {
return match
}
fallbackKind := ""
normalized := normalizeOrganizeMediaType(kind)
if normalized == "anime" {
if isTVMetadataKind(kind) {
fallbackKind = "movie"
} else {
fallbackKind = "tv"
}
}
if fallbackKind != "" {
if fbMatch := s.lookupAutomaticTMDbPrimary(ctx, fallbackKind, query, year); fbMatch != nil {
return fbMatch
}
}
return nil
}
func (s *ScraperService) lookupAutomaticTMDbPrimary(ctx context.Context, kind, query string, year int) *Match {
var (
candidates []*Match
err error
mediaLabel string
)
if isTVMetadataKind(kind) {
mediaLabel = "tv"
candidates, err = s.tmdb.SearchTVCandidates(ctx, query, year)
} else {
mediaLabel = "movie"
candidates, err = s.tmdb.SearchMovieCandidates(ctx, query, year)
}
if err != nil {
s.log.Debug("tmdb "+mediaLabel+" search failed", zap.String("query", query), zap.Error(err))
return nil
}
if match := bestAutomaticMetadataMatch(query, year, kind, candidates); match != nil {
return s.localizeAutomaticTMDbMatch(ctx, kind, query, match)
}
if !queryNeedsEnglishTMDbFallback(query) {
return nil
}
var alternate []*Match
if isTVMetadataKind(kind) {
alternate, err = s.tmdb.searchTVCandidates(ctx, query, year, "en-US")
} else {
alternate, err = s.tmdb.searchMovieCandidates(ctx, query, year, "en-US")
}
if err != nil {
s.log.Debug("tmdb "+mediaLabel+" alternate-language search failed", zap.String("query", query), zap.Error(err))
return nil
}
match := bestAutomaticMetadataMatch(query, year, kind, mergeTMDbLanguageCandidates(candidates, alternate))
return s.localizeAutomaticTMDbMatch(ctx, kind, query, match)
}
func (s *ScraperService) localizeAutomaticTMDbMatch(ctx context.Context, kind, query string, match *Match) *Match {
if s == nil || s.tmdb == nil || match == nil || match.TMDbID <= 0 || !metadataTitleNeedsChineseLocalization(match) {
return match
}
var (
localized *Match
err error
)
if isTVMetadataKind(kind) {
localized, err = s.tmdb.GetTVMatch(ctx, match.TMDbID)
} else {
localized, err = s.tmdb.GetMovieMatch(ctx, match.TMDbID)
}
if err != nil || localized == nil {
if err != nil && s.log != nil {
s.log.Debug("tmdb localized title lookup failed",
zap.String("query", query),
zap.Int("tmdb_id", match.TMDbID),
zap.Error(err))
}
return match
}
mergeAutomaticTMDbLocalizedMatch(localized, match)
localized.SearchKeyword = query
localized.Aliases = appendMetadataAliases(localized.Aliases,
match.Title, match.OriginalName)
localized.Aliases = appendMetadataAliases(localized.Aliases, match.Aliases...)
return localized
}
func mergeAutomaticTMDbLocalizedMatch(localized, search *Match) {
if localized == nil || search == nil {
return
}
if strings.TrimSpace(localized.OriginalName) == "" {
localized.OriginalName = strings.TrimSpace(firstNonEmpty(search.OriginalName, search.Title))
}
localized.Overview = firstNonEmpty(localized.Overview, search.Overview)
localized.PosterURL = firstNonEmpty(localized.PosterURL, search.PosterURL)
localized.BackdropURL = firstNonEmpty(localized.BackdropURL, search.BackdropURL)
localized.MediaType = firstNonEmpty(localized.MediaType, search.MediaType)
if localized.Year <= 0 {
localized.Year = search.Year
}
if localized.ReleaseDate == "" {
localized.ReleaseDate = search.ReleaseDate
}
if localized.Rating <= 0 {
localized.Rating = search.Rating
}
if len(localized.Languages) == 0 {
localized.Languages = append([]string(nil), search.Languages...)
}
if len(localized.Countries) == 0 {
localized.Countries = append([]string(nil), search.Countries...)
}
if len(localized.Genres) == 0 {
localized.Genres = append([]string(nil), search.Genres...)
}
}
func bestAutomaticMetadataMatch(query string, year int, expectedType string, candidates []*Match) *Match {
bestScore := -1
var best *Match
for index, candidate := range candidates {
if candidate == nil || !metadataMatchCompatibleWithType(expectedType, candidate) {
continue
}
if !organizeMetadataMatchTrusted(query, year, candidate) {
continue
}
score := automaticMetadataMatchScore(query, year, candidate) - index
if score > bestScore {
bestScore = score
best = candidate
}
}
return best
}
func automaticMetadataMatchScore(query string, year int, match *Match) int {
queryKey := metadataTrustKey(query)
score := 0
titles := make([]string, 0, 2+len(match.Aliases))
titles = append(titles, match.Title, match.OriginalName)
titles = append(titles, match.Aliases...)
for _, title := range titles {
titleKey := metadataTrustKey(title)
switch {
case titleKey != "" && titleKey == queryKey:
if score < 1000 {
score = 1000
}
case metadataTrustTokenOverlap(queryKey, titleKey):
if score < 700 {
score = 700
}
}
}
if year > 0 && match.Year > 0 {
diff := year - match.Year
if diff < 0 {
diff = -diff
}
switch diff {
case 0:
score += 200
case 1:
score += 100
}
}
if metadataMatchHasExternalID(match) {
score += 20
}
if strings.TrimSpace(match.PosterURL) != "" {
score += 5
}
return score
}
func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
if match == nil {
return false
}
expectedType = normalizeOrganizeMediaType(expectedType)
matchType := normalizeOrganizeMediaType(match.MediaType)
if expectedType == "" || matchType == "" {
return true
}
switch expectedType {
case "tv", "variety":
return matchType == "tv" || matchType == "anime" || matchType == "variety"
case "anime":
return matchType == "anime" || matchType == "tv" || matchType == "movie" || matchType == "variety"
case "movie", "adult":
return matchType == "movie" || matchType == "adult"
default:
return expectedType == matchType
}
}
func metadataMatchCompatibleWithTheatrical(expectedType string, isTheatrical bool, match *Match) bool {
if isTheatrical &&
normalizeOrganizeMediaType(expectedType) == "movie" &&
match != nil &&
normalizeOrganizeMediaType(match.MediaType) == "anime" {
return true
}
return metadataMatchCompatibleWithType(expectedType, match)
}
func queryNeedsEnglishTMDbFallback(query string) bool {
for _, r := range query {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return true
}
}
return false
}
func mergeTMDbLanguageCandidates(primary, alternate []*Match) []*Match {
if len(primary) == 0 {
return alternate
}
byID := make(map[int]*Match, len(primary))
out := append([]*Match(nil), primary...)
for _, candidate := range primary {
if candidate != nil && candidate.TMDbID > 0 {
byID[candidate.TMDbID] = candidate
}
}
for _, candidate := range alternate {
if candidate == nil {
continue
}
if localized := byID[candidate.TMDbID]; localized != nil {
localized.Aliases = appendMetadataAliases(localized.Aliases,
candidate.Title, candidate.OriginalName)
localized.Aliases = appendMetadataAliases(localized.Aliases, candidate.Aliases...)
continue
}
out = append(out, candidate)
}
return out
}
func appendMetadataAliases(existing []string, values ...string) []string {
seen := make(map[string]struct{}, len(existing)+len(values))
out := make([]string, 0, len(existing)+len(values))
add := func(value string) {
value = strings.TrimSpace(value)
key := metadataTrustKey(value)
if value == "" || key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, value)
}
for _, value := range existing {
add(value)
}
for _, value := range values {
add(value)
}
return out
}