162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
package recognize
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
|
)
|
|
|
|
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
|
|
const maxCandidates = 8
|
|
|
|
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
|
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
|
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
|
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
|
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
|
if len(r.providers) == 0 {
|
|
return nil, nil
|
|
}
|
|
mt := metadata.Movie
|
|
if plan.Type == MediaSeries {
|
|
mt = metadata.Series
|
|
}
|
|
|
|
searchTitle := plan.ProviderHint
|
|
if strings.TrimSpace(searchTitle) == "" {
|
|
searchTitle = plan.Title
|
|
}
|
|
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
|
|
|
var match *Match
|
|
var candidates []metadata.Candidate
|
|
seen := map[string]bool{}
|
|
|
|
for _, p := range r.providers {
|
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
|
if err != nil {
|
|
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
|
continue
|
|
}
|
|
|
|
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
|
for _, c := range cands {
|
|
key := c.Provider + ":" + c.ID
|
|
if seen[key] || len(candidates) >= maxCandidates {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
candidates = append(candidates, c)
|
|
}
|
|
|
|
// Единичный сильный матч ищем у первого подходящего провайдера.
|
|
if match != nil {
|
|
continue
|
|
}
|
|
strong := strongMatches(cands, plan.Year, matchTitles)
|
|
if len(strong) != 1 {
|
|
continue
|
|
}
|
|
match = r.buildMatch(ctx, p, strong[0], mt)
|
|
}
|
|
return match, candidates
|
|
}
|
|
|
|
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
|
// тег-предпочтительным провенансом.
|
|
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
|
var counts map[int]int
|
|
if mt == metadata.Series {
|
|
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
|
|
counts = got
|
|
} else {
|
|
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
|
|
}
|
|
}
|
|
prov, pid := CandidateTag(c)
|
|
return &Match{
|
|
Provider: prov,
|
|
ProviderID: pid,
|
|
Title: c.Title,
|
|
Year: c.Year,
|
|
SeasonEpisodeCounts: counts,
|
|
}
|
|
}
|
|
|
|
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
|
|
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
|
|
// поиска. Используется и в матче, и при сохранении кандидатов.
|
|
func CandidateTag(c metadata.Candidate) (provider, id string) {
|
|
if c.TagProvider != "" {
|
|
return c.TagProvider, c.TagID
|
|
}
|
|
return c.Provider, c.ID
|
|
}
|
|
|
|
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
|
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
|
// по id.
|
|
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
|
|
seen := map[string]bool{}
|
|
var out []metadata.Candidate
|
|
for _, c := range cands {
|
|
if !yearMatches(year, c.Year) {
|
|
continue
|
|
}
|
|
if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] {
|
|
continue
|
|
}
|
|
if seen[c.ID] {
|
|
continue
|
|
}
|
|
seen[c.ID] = true
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные
|
|
// базы по-разному датируют релиз), либо где-то год неизвестен.
|
|
func yearMatches(a, b int) bool {
|
|
if a == 0 || b == 0 {
|
|
return true
|
|
}
|
|
d := a - b
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
return d <= 1
|
|
}
|
|
|
|
// normSet — множество нормализованных непустых названий.
|
|
func normSet(titles ...string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, t := range titles {
|
|
if n := normalize(t); n != "" {
|
|
out[n] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalize приводит название к сравнимому виду: нижний регистр, только
|
|
// буквы/цифры (юникод), одиночные пробелы.
|
|
func normalize(s string) string {
|
|
var b strings.Builder
|
|
prevSpace := false
|
|
for _, r := range strings.ToLower(s) {
|
|
switch {
|
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
|
b.WriteRune(r)
|
|
prevSpace = false
|
|
case !prevSpace:
|
|
b.WriteByte(' ')
|
|
prevSpace = true
|
|
}
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|