Files
silo-server/internal/metadata/match_candidates.go
T

624 lines
16 KiB
Go

package metadata
import (
"context"
"math"
"sort"
"strconv"
"strings"
"unicode"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/naming"
)
// MatchCandidate represents a deduplicated search result grouped by normalized
// provider IDs. Multiple raw SearchResult rows from different providers that
// share the same TMDB/TVDB/IMDB IDs are collapsed into a single candidate.
type MatchCandidate struct {
Title string `json:"title"`
Year int `json:"year"`
ContentType string `json:"content_type"`
ProviderIDs map[string]string `json:"provider_ids"`
ImageURL string `json:"image_url,omitempty"`
Overview string `json:"overview,omitempty"`
Sources []string `json:"sources"`
AgreementHints []string `json:"agreement_hints"`
DetailScore int `json:"-"`
}
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
func compatibleProviderIDs(left, right map[string]string) bool {
overlap := false
for _, key := range canonicalCandidateIDKeys {
lv := strings.TrimSpace(left[key])
rv := strings.TrimSpace(right[key])
if lv == "" || rv == "" {
continue
}
if lv != rv {
return false
}
overlap = true
}
return overlap
}
func providerIDRichness(ids map[string]string) int {
score := 0
for _, key := range canonicalCandidateIDKeys {
if strings.TrimSpace(ids[key]) != "" {
score++
}
}
return score
}
const (
minimumDetailTieBreakScore = 20
minimumDetailTieBreakGap = 12
)
func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
if hints == nil || len(scoredCandidates) < 2 {
return nil, false
}
best := scoredCandidates[0]
contenders := []scoredMatchCandidate{best}
for i := 1; i < len(scoredCandidates); i++ {
next := scoredCandidates[i]
if best.score-next.score >= 15 {
break
}
if duplicateTieBreakComparable(hints, best.candidate, next.candidate) {
contenders = append(contenders, next)
}
}
if len(contenders) < 2 {
return nil, false
}
sort.SliceStable(contenders, func(i, j int) bool {
return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore
})
if contenders[0].candidate.DetailScore < minimumDetailTieBreakScore {
return nil, false
}
if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap {
return nil, false
}
return &contenders[0].candidate, true
}
func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool {
if hints == nil {
return false
}
if hints.Year == 0 || left.Year == 0 || right.Year == 0 {
return false
}
if left.Year != hints.Year || right.Year != hints.Year || left.Year != right.Year {
return false
}
if !candidateTypeMatchesHint(hints.Type, left.ContentType) ||
!candidateTypeMatchesHint(hints.Type, right.ContentType) {
return false
}
if strings.TrimSpace(left.ContentType) != "" &&
strings.TrimSpace(right.ContentType) != "" &&
!strings.EqualFold(left.ContentType, right.ContentType) {
return false
}
if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 {
return false
}
return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs)
}
func candidateTypeMatchesHint(hintType, candidateType string) bool {
hintType = strings.ToLower(strings.TrimSpace(hintType))
candidateType = strings.ToLower(strings.TrimSpace(candidateType))
if hintType == "" || candidateType == "" {
return true
}
if hintType == candidateType {
return true
}
return isMovieTypeAlias(hintType) && isMovieTypeAlias(candidateType)
}
func isMovieTypeAlias(value string) bool {
switch value {
case "movie", "movies":
return true
default:
return false
}
}
func samePrimaryProvider(left, right map[string]string) bool {
for _, key := range canonicalCandidateIDKeys {
leftValue := strings.TrimSpace(left[key])
rightValue := strings.TrimSpace(right[key])
if leftValue != "" && rightValue != "" {
return true
}
}
return false
}
// normalizedKey returns a stable grouping key from provider IDs.
// Results with identical provider ID fingerprints (the exact set of
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
func normalizedKey(ids map[string]string) string {
var parts []string
for _, k := range canonicalCandidateIDKeys {
if v, ok := ids[k]; ok && v != "" {
parts = append(parts, k+"="+v)
}
}
if len(parts) == 0 {
// Fall back to metadb if present.
if v, ok := ids["metadb"]; ok && v != "" {
return "metadb=" + v
}
return ""
}
return strings.Join(parts, ",")
}
// NormalizeCandidates deduplicates raw search results into MatchCandidate
// entries. Results with identical provider ID fingerprints are merged:
// provider IDs are unioned, sources list every provider slug that returned
// the result, and agreement_hints notes when multiple providers agree.
func NormalizeCandidates(results []SearchResult, contentType string) []MatchCandidate {
type bucket struct {
candidate MatchCandidate
sources map[string]bool
}
ordered := make([]string, 0)
buckets := make(map[string]*bucket)
for _, sr := range results {
key := ""
for _, existingKey := range ordered {
if compatibleProviderIDs(buckets[existingKey].candidate.ProviderIDs, sr.ProviderIDs) {
key = existingKey
break
}
}
if key == "" {
key = normalizedKey(sr.ProviderIDs)
}
if key == "" {
// Cannot group by provider IDs; create a synthetic unique key.
key = sr.Provider + ":" + sr.Name + ":" + strings.Repeat("?", len(ordered))
}
b, exists := buckets[key]
if !exists {
b = &bucket{
candidate: MatchCandidate{
Title: sr.Name,
Year: sr.Year,
ContentType: contentType,
ProviderIDs: make(map[string]string),
ImageURL: sr.ImageURL,
Overview: sr.Overview,
},
sources: make(map[string]bool),
}
buckets[key] = b
ordered = append(ordered, key)
}
// Merge provider IDs.
for k, v := range sr.ProviderIDs {
if v != "" {
b.candidate.ProviderIDs[k] = v
}
}
// Track source providers.
if sr.Provider != "" {
b.sources[sr.Provider] = true
}
// Prefer non-empty overview and image.
if b.candidate.Overview == "" && sr.Overview != "" {
b.candidate.Overview = sr.Overview
}
if b.candidate.ImageURL == "" && sr.ImageURL != "" {
b.candidate.ImageURL = sr.ImageURL
}
}
// Build final list preserving insertion order.
candidates := make([]MatchCandidate, 0, len(ordered))
for _, key := range ordered {
b := buckets[key]
// Flatten sources.
sources := make([]string, 0, len(b.sources))
for s := range b.sources {
sources = append(sources, s)
}
sort.Strings(sources)
b.candidate.Sources = sources
// Compute agreement hints.
if len(sources) > 1 {
b.candidate.AgreementHints = append(b.candidate.AgreementHints,
"agreed_by_"+strings.Join(sources, "_and_"))
}
candidates = append(candidates, b.candidate)
}
return candidates
}
// SearchAndNormalize is a convenience method that calls SearchProviders and
// normalizes the results into MatchCandidates. Plugin-prefixed image URLs
// (e.g. "metadb://...") are resolved to presigned HTTP URLs before returning.
func (s *MetadataService) SearchAndNormalize(ctx context.Context, query SearchQuery, folderID int) ([]MatchCandidate, error) {
results, err := s.SearchProviders(ctx, query, folderID)
if err != nil {
return nil, err
}
candidates := NormalizeCandidates(results, query.ContentType)
if s.imageResolver != nil {
for i, c := range candidates {
if c.ImageURL != "" && strings.Contains(c.ImageURL, "://") {
resolved := s.imageResolver.ResolveImageURL(ctx, c.ImageURL, "card")
if resolved != "" {
candidates[i].ImageURL = resolved
}
}
}
}
return candidates, nil
}
func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
if hints == nil {
return 0
}
score := 0.0
trustedIDMatches := 0
for _, key := range trustedSearchIDKeys {
hintValue := trustedIDValue(hints, key)
if hintValue == "" {
continue
}
if candidate.ProviderIDs[key] == hintValue {
score += 100
trustedIDMatches++
}
}
if trustedIDMatches > 0 {
score += float64(trustedIDMatches * 10)
}
score += float64(len(candidate.Sources) * 12)
if strings.TrimSpace(hints.Title) != "" && strings.TrimSpace(candidate.Title) != "" {
titleSimilarity := inferTitleSimilarity(hints.Title, candidate.Title, hints.Year)
if titleSimilarity == 1 {
score += 45
} else {
score += titleSimilarity * 35
}
}
switch {
case hints.Year != 0 && candidate.Year == hints.Year:
score += 20
case hints.Year != 0 && candidate.Year != 0 && math.Abs(float64(candidate.Year-hints.Year)) == 1:
score += 5
}
if len(candidate.ProviderIDs) > 0 {
score += 5
score += float64(providerIDRichness(candidate.ProviderIDs))
}
return score
}
type scoredMatchCandidate struct {
candidate MatchCandidate
score float64
}
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
if len(candidates) == 0 {
return nil, false
}
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
for _, candidate := range candidates {
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
candidate: candidate,
score: scoreMatchCandidate(hints, candidate),
})
}
sort.SliceStable(scoredCandidates, func(i, j int) bool {
return scoredCandidates[i].score > scoredCandidates[j].score
})
best := scoredCandidates[0]
if trustedHintIDsPresent(hints) {
if candidateMatchesTrustedIDs(hints, best.candidate) {
return &best.candidate, true
}
return nil, false
}
if best.score < 55 {
return nil, false
}
if len(scoredCandidates) == 1 {
if best.score < 70 {
return nil, false
}
return &best.candidate, true
}
if best.score-scoredCandidates[1].score < 15 {
if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok {
return winner, true
}
return providerOrderExactTieBreakWinner(hints, scoredCandidates)
}
return &best.candidate, true
}
func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
if hints == nil || len(scoredCandidates) < 2 {
return nil, false
}
best := scoredCandidates[0]
contenders := []scoredMatchCandidate{best}
for i := 1; i < len(scoredCandidates); i++ {
next := scoredCandidates[i]
if best.score-next.score >= 15 {
break
}
contenders = append(contenders, next)
}
if len(contenders) < 2 {
return nil, false
}
seenPrimaryProviders := make(map[string]struct{}, len(contenders))
for _, contender := range contenders {
if !exactTitleYearTypeMatch(hints, contender.candidate) {
return nil, false
}
primaryProvider := candidatePrimaryProvider(contender.candidate)
if primaryProvider == "" {
return nil, false
}
if _, exists := seenPrimaryProviders[primaryProvider]; exists {
return nil, false
}
seenPrimaryProviders[primaryProvider] = struct{}{}
}
return &best.candidate, true
}
func exactTitleYearTypeMatch(hints *MatchHints, candidate MatchCandidate) bool {
if hints == nil || hints.Year == 0 || candidate.Year == 0 {
return false
}
if candidate.Year != hints.Year {
return false
}
if !candidateTypeMatchesHint(hints.Type, candidate.ContentType) {
return false
}
return inferTitleSimilarity(hints.Title, candidate.Title, hints.Year) == 1
}
func candidatePrimaryProvider(candidate MatchCandidate) string {
for _, key := range canonicalCandidateIDKeys {
if strings.TrimSpace(candidate.ProviderIDs[key]) != "" {
return key
}
}
if len(candidate.Sources) == 1 {
return strings.TrimSpace(candidate.Sources[0])
}
return ""
}
func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchCandidate) (*MatchCandidate, bool) {
if existing == nil || len(candidates) == 0 {
return nil, false
}
hints := &MatchHints{
Title: existing.Title,
Year: existing.Year,
Type: existing.Type,
TmdbID: existing.TmdbID,
TvdbID: existing.TvdbID,
ImdbID: existing.ImdbID,
}
return selectInitialMatchCandidate(hints, candidates)
}
func trustedHintIDsPresent(hints *MatchHints) bool {
for _, key := range trustedSearchIDKeys {
if trustedIDValue(hints, key) != "" {
return true
}
}
return false
}
func candidateMatchesTrustedIDs(hints *MatchHints, candidate MatchCandidate) bool {
matched := false
for _, key := range trustedSearchIDKeys {
hintValue := trustedIDValue(hints, key)
if hintValue == "" {
continue
}
candidateValue := candidate.ProviderIDs[key]
if candidateValue == "" {
continue
}
if candidateValue != hintValue {
return false
}
matched = true
}
return matched
}
func trustedIDValue(hints *MatchHints, key string) string {
if hints == nil {
return ""
}
switch key {
case "metadb":
return hints.ContentID
case "tmdb":
return hints.TmdbID
case "tvdb":
return hints.TvdbID
case "imdb":
return hints.ImdbID
default:
return ""
}
}
func normalizeCandidateTitle(title string) string {
return strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
}
func inferTitleSimilarity(left, right string, year int) float64 {
leftNorm := normalizeCandidateTitleForYear(left, year)
rightNorm := normalizeCandidateTitleForYear(right, year)
if leftNorm == "" || rightNorm == "" {
return 0
}
if leftNorm == rightNorm {
return 1
}
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(leftNorm)), " ")
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(rightNorm)), " ")
if leftComparable == rightComparable {
return 1
}
if naming.InferTitlesCoherent(left, right) {
return 0.8
}
return 0
}
func normalizeCandidateTitleForYear(title string, year int) string {
normalized := normalizeCandidateTitle(title)
if normalized == "" || year == 0 {
return normalized
}
yearText := strconv.Itoa(year)
fields := strings.Fields(normalized)
if len(fields) <= 1 || fields[len(fields)-1] != yearText {
return normalized
}
return strings.Join(fields[:len(fields)-1], " ")
}
func normalizeTitleForScoring(title string) string {
title = naming.StripComparisonSafeEditionSuffix(title)
title = strings.ToLower(strings.TrimSpace(title))
if title == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(title))
lastComparableWasAlnum := false
for _, r := range title {
if digit, ok := normalizeNumericRune(r); ok {
if isStyledNumericRune(r) && lastComparableWasAlnum {
builder.WriteByte(' ')
}
builder.WriteRune(digit)
lastComparableWasAlnum = true
continue
}
switch {
case unicode.IsLetter(r):
builder.WriteRune(r)
lastComparableWasAlnum = true
case r == '&':
builder.WriteString(" and ")
lastComparableWasAlnum = true
case r == '\'':
// Collapse contractions like "what's" -> "whats" so scanner- and
// provider-derived variants can compare as exact.
default:
builder.WriteByte(' ')
lastComparableWasAlnum = false
}
}
return strings.Join(strings.Fields(builder.String()), " ")
}
func normalizeNumericRune(r rune) (rune, bool) {
switch r {
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
return r, true
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
}
}
func isStyledNumericRune(r rune) bool {
switch r {
case '⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹', '₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉':
return true
default:
return false
}
}