Добавил выбор из кандидатов, если LLM не уверена в раскладке

This commit is contained in:
2026-06-14 16:43:50 +03:00
parent 4af3ad2dde
commit 7f7f5f69d4
16 changed files with 831 additions and 88 deletions
+60 -32
View File
@@ -8,14 +8,17 @@ import (
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
// если ровно один кандидат уверенно совпадает (название и год), возвращает
// матч с официальным id и каноническим именем. Несколько кандидатов или их
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
// валят распознавание — просто нет матча.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
// 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
return nil, nil
}
mt := metadata.Movie
if plan.Type == MediaSeries {
@@ -28,44 +31,69 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
}
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
}
c := strong[0]
match = r.buildMatch(ctx, p, strong[0], mt)
}
return match, candidates
}
// Число серий тянем по нативному id провайдера.
var counts map[int]int
if mt == metadata.Series {
if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
counts = got
} else {
r.log.Warn("recognize: episode counts failed",
"provider", p.Name(), "id", c.ID, "err", cerr)
}
}
// Провенанс и тег папки — по внешнему id, если провайдер его дал
// (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
prov, pid := c.Provider, c.ID
if c.TagProvider != "" {
prov, pid = c.TagProvider, c.TagID
}
return &Match{
Provider: prov,
ProviderID: pid,
Title: c.Title,
Year: c.Year,
SeasonEpisodeCounts: counts,
// 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)
}
}
return nil
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 оставляет кандидатов, чьё название совпадает с одним из