Добавил выбор из кандидатов, если LLM не уверена в раскладке
This commit is contained in:
@@ -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 оставляет кандидатов, чьё название совпадает с одним из
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
|
||||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
||||
{ID: "2", Title: "Fargo", Year: 2014},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||||
t.Errorf("ambiguous must not match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
|
||||
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
|
||||
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||||
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||||
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
|
||||
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, cands := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
|
||||
if m != nil {
|
||||
t.Errorf("strong match не ожидался: %+v", m)
|
||||
}
|
||||
if len(cands) != 2 { // дубль отброшен
|
||||
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
|
||||
}
|
||||
// CandidateTag даёт внешний TVDB-id для первого.
|
||||
prov, id := CandidateTag(cands[0])
|
||||
if prov != "tvdb" || id != "269613" {
|
||||
t.Errorf("tag = %s/%s", prov, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognize_PopulatesCandidates(t *testing.T) {
|
||||
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
|
||||
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
|
||||
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
|
||||
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||||
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
|
||||
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
|
||||
}}
|
||||
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Recognize: %v", err)
|
||||
}
|
||||
if res.Match != nil {
|
||||
t.Errorf("strong match не ожидался")
|
||||
}
|
||||
if len(res.Candidates) != 2 {
|
||||
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||||
t.Errorf("year mismatch must not match, got %+v", m)
|
||||
}
|
||||
@@ -81,7 +125,7 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||||
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Errorf("should match by original title, got %+v", m)
|
||||
@@ -98,7 +142,7 @@ func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||
counts: map[int]int{1: 10},
|
||||
}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
@@ -118,7 +162,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||
counts: map[int]int{1: 10, 2: 10},
|
||||
}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||||
t.Errorf("counts not fetched: %+v", m)
|
||||
@@ -128,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||||
t.Errorf("provider error must yield no match, got %+v", m)
|
||||
}
|
||||
@@ -136,7 +180,7 @@ func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||
|
||||
func TestMatchMetadata_Disabled(t *testing.T) {
|
||||
r := recognizerWith(nil)
|
||||
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
||||
if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
||||
t.Errorf("no providers → no match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +115,13 @@ type Match struct {
|
||||
|
||||
// Result — итог распознавания.
|
||||
type Result struct {
|
||||
Plan Plan
|
||||
PreParse PreParse
|
||||
Decision Decision
|
||||
Match *Match // подтверждённый матч в базе (nil — нет)
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
||||
Plan Plan
|
||||
PreParse PreParse
|
||||
Decision Decision
|
||||
Match *Match // подтверждённый единичный матч (nil — нет)
|
||||
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||
Raw string // сырой ответ LLM последней попытки
|
||||
}
|
||||
|
||||
// LLM — нужная recognize часть провайдера.
|
||||
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
}
|
||||
|
||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||
// в плане заменяем на каноничные.
|
||||
match := r.matchMetadata(ctx, plan)
|
||||
// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
|
||||
// review, когда единичного сильного матча нет.
|
||||
match, candidates := r.matchMetadata(ctx, plan)
|
||||
if match != nil {
|
||||
plan.Title = match.Title
|
||||
if match.Year != 0 {
|
||||
@@ -247,13 +249,15 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
r.log.Info("recognize: done",
|
||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||
"files", len(plan.Files), "attempts", attempts,
|
||||
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||
"matched", match != nil, "candidates", len(candidates),
|
||||
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||
return Result{
|
||||
Plan: plan,
|
||||
PreParse: pre,
|
||||
Decision: dec,
|
||||
Match: match,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
Plan: plan,
|
||||
PreParse: pre,
|
||||
Decision: dec,
|
||||
Match: match,
|
||||
Candidates: candidates,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user