Поиск по нескольким названиям при сверке с базой (recognition)
Сверка с метабазой промахивалась на иностранных фильмах с русским релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке provider_hint||title и игнорировал original_title, а базы индексированы прежде всего по оригинальным названиям. - matchMetadata ищет по ключам original_title → title → provider_hint с ранним стопом на первом единичном сильном матче; пустые и нормализованно-дублирующие ключи пропускаются, кандидаты для review копятся из всех заходов. - Промпт требует всегда заполнять title и original_title (дублировать при отсутствии оригинала / российском контенте; при неуверенности дублировать, не выдумывать). Разбор остаётся мягким к пустому original_title. - TMDB-поиск передаёт language (по умолчанию ru-RU, настраивается [metadata.tmdb].language); original_title не зависит от локали. - Нормализация названий сводит ё→е. Инварианты не ослаблены: авто только при подтверждённом единичном матче + структурной валидации + согласованности сигналов. Capability recognition впервые перенесена в OpenSpec; change архивирован. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,12 +76,14 @@ type Metadata struct {
|
||||
}
|
||||
|
||||
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||
// (TVMaze) поле api_key не используется.
|
||||
// (TVMaze) поле api_key не используется; language учитывает только TMDB
|
||||
// (локаль возвращаемых названий, дефолт ru-RU).
|
||||
type MetadataProvider struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Proxy string `toml:"proxy"`
|
||||
Timeout Duration `toml:"timeout"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Proxy string `toml:"proxy"`
|
||||
Timeout Duration `toml:"timeout"`
|
||||
Language string `toml:"language"`
|
||||
}
|
||||
|
||||
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
||||
@@ -172,7 +174,7 @@ func Default() *Config {
|
||||
MaxRetries: 3,
|
||||
},
|
||||
Metadata: Metadata{
|
||||
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second), Language: "ru-RU"},
|
||||
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
},
|
||||
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
||||
|
||||
+20
-11
@@ -13,22 +13,27 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
)
|
||||
|
||||
const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
||||
const (
|
||||
tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
|
||||
tmdbDefaultLanguage = "ru-RU"
|
||||
)
|
||||
|
||||
// TMDBConfig — настройки клиента TMDB.
|
||||
type TMDBConfig struct {
|
||||
APIKey string
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
||||
APIKey string
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
||||
Language string // локаль возвращаемых названий; пусто → ru-RU
|
||||
}
|
||||
|
||||
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
||||
type TMDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
apiKey string
|
||||
baseURL string
|
||||
language string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
||||
@@ -44,10 +49,14 @@ func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
||||
if base == "" {
|
||||
base = tmdbDefaultBaseURL
|
||||
}
|
||||
lang := cfg.Language
|
||||
if lang == "" {
|
||||
lang = tmdbDefaultLanguage
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
||||
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), language: lang, hc: hc, log: logger}, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) Name() string { return "tmdb" }
|
||||
@@ -67,7 +76,7 @@ type tmdbSearchResp struct {
|
||||
// Search ищет фильм/сериал по названию и году.
|
||||
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
var path string
|
||||
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}}
|
||||
params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}, "language": {t.language}}
|
||||
switch q.Type {
|
||||
case Movie:
|
||||
path = "/search/movie"
|
||||
|
||||
@@ -102,6 +102,45 @@ func TestTMDB_ErrorStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SearchDefaultLanguage(t *testing.T) {
|
||||
// Язык не задан в конфиге → дефолт ru-RU; original_title не зависит от локали.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("language"); got != "ru-RU" {
|
||||
t.Errorf("language = %q, want ru-RU", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[
|
||||
{"id":155,"title":"Тёмный рыцарь","original_title":"The Dark Knight","release_date":"2008-07-18"}
|
||||
]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Dark Knight", Year: 2008})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if got[0].Title != "Тёмный рыцарь" || got[0].OriginalTitle != "The Dark Knight" {
|
||||
t.Errorf("candidate = %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SearchLanguageOverride(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("language"); got != "en-US" {
|
||||
t.Errorf("language = %q, want en-US", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "en-US"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTMDB: %v", err)
|
||||
}
|
||||
if _, err := c.Search(context.Background(), Query{Type: Movie, Title: "X"}); err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTMDB_RequiresKey(t *testing.T) {
|
||||
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
||||
t.Fatal("want error without api_key")
|
||||
|
||||
@@ -15,8 +15,13 @@ const maxCandidates = 8
|
||||
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
||||
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
|
||||
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||
//
|
||||
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
|
||||
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||||
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||||
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||
if len(r.providers) == 0 {
|
||||
return nil, nil
|
||||
@@ -26,48 +31,71 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
||||
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 {
|
||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
for _, c := range cands {
|
||||
key := c.Provider + ":" + c.ID
|
||||
if seen[key] || len(candidates) >= maxCandidates {
|
||||
for _, key := range searchKeys(plan) {
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
||||
if err != nil {
|
||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
|
||||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
for _, c := range cands {
|
||||
ck := c.Provider + ":" + c.ID
|
||||
if seen[ck] || len(candidates) >= maxCandidates {
|
||||
continue
|
||||
}
|
||||
seen[ck] = 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)
|
||||
}
|
||||
if match != nil {
|
||||
continue
|
||||
break
|
||||
}
|
||||
strong := strongMatches(cands, plan.Year, matchTitles)
|
||||
if len(strong) != 1 {
|
||||
continue
|
||||
}
|
||||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||
}
|
||||
return match, candidates
|
||||
}
|
||||
|
||||
// searchKeys строит ключи поиска по базе в порядке убывания силы:
|
||||
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
|
||||
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
|
||||
// же запросом (частый случай — российский фильм, где original_title дублирует
|
||||
// title).
|
||||
func searchKeys(plan Plan) []string {
|
||||
var keys []string
|
||||
seen := map[string]bool{}
|
||||
for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
continue
|
||||
}
|
||||
n := normalize(t)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
keys = append(keys, t)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
||||
// тег-предпочтительным провенансом.
|
||||
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
||||
@@ -146,11 +174,15 @@ func normSet(titles ...string) map[string]bool {
|
||||
}
|
||||
|
||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||
// буквы/цифры (юникод), одиночные пробелы.
|
||||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||
// расхождение написания: «Тёмный» vs «Темный»).
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
prevSpace := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if r == 'ё' {
|
||||
r = 'е'
|
||||
}
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
|
||||
@@ -3,6 +3,7 @@ package recognize
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
@@ -11,9 +12,11 @@ import (
|
||||
type fakeProvider struct {
|
||||
name string
|
||||
candidates []metadata.Candidate
|
||||
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||
counts map[int]int
|
||||
searchErr error
|
||||
searched int
|
||||
queries []string // строки запросов в порядке вызова
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Name() string {
|
||||
@@ -22,9 +25,16 @@ func (f *fakeProvider) Name() string {
|
||||
}
|
||||
return f.name
|
||||
}
|
||||
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
|
||||
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
|
||||
f.searched++
|
||||
return f.candidates, f.searchErr
|
||||
f.queries = append(f.queries, q.Title)
|
||||
if f.searchErr != nil {
|
||||
return nil, f.searchErr
|
||||
}
|
||||
if f.byTitle != nil {
|
||||
return f.byTitle[q.Title], nil
|
||||
}
|
||||
return f.candidates, nil
|
||||
}
|
||||
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||
return f.counts, nil
|
||||
@@ -132,6 +142,75 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) {
|
||||
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
|
||||
// При сильном матче по первому ключу дальнейшие запросы не делаются.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"The Dark Knight": {{Provider: "tmdb", ID: "155", Title: "The Dark Knight", Year: 2008}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Тёмный рыцарь", OriginalTitle: "The Dark Knight", Year: 2008})
|
||||
if m == nil || m.ProviderID != "155" {
|
||||
t.Fatalf("should match by original title, got %+v", m)
|
||||
}
|
||||
if want := []string{"The Dark Knight"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v (ранний стоп на оригинале)", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_FallbackToTitle(t *testing.T) {
|
||||
// Запрос по original ничего не дал — фолбэк на локализованный title.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Wrong Original": {},
|
||||
"Fargo": {{Provider: "tmdb", ID: "1", Title: "Fargo", Year: 2014}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", OriginalTitle: "Wrong Original", Year: 2014})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Fatalf("should fall back to title, got %+v", m)
|
||||
}
|
||||
if want := []string{"Wrong Original", "Fargo"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_FallbackToProviderHint(t *testing.T) {
|
||||
// Ни original, ни title не нашли — третий фолбэк по provider_hint.
|
||||
// Гейт по-прежнему требует совпадения названия кандидата с title/original.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Orig": {},
|
||||
"Loc": {},
|
||||
"Hint": {{Provider: "tmdb", ID: "7", Title: "Loc", Year: 2000}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Loc", OriginalTitle: "Orig", ProviderHint: "Hint", Year: 2000})
|
||||
if m == nil || m.ProviderID != "7" {
|
||||
t.Fatalf("should fall back to provider_hint, got %+v", m)
|
||||
}
|
||||
if want := []string{"Orig", "Loc", "Hint"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_SkipsDuplicateKey(t *testing.T) {
|
||||
// Российский фильм: original_title дублирует title — база дёргается раз.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Брат": {{Provider: "tmdb", ID: "1", Title: "Брат", Year: 1997}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Брат", OriginalTitle: "Брат", Year: 1997})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Fatalf("expected match, got %+v", m)
|
||||
}
|
||||
if p.searched != 1 {
|
||||
t.Errorf("searched = %d, want 1 (дубль-ключ пропущен)", p.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||
p := &fakeProvider{
|
||||
@@ -191,6 +270,8 @@ func TestNormalize(t *testing.T) {
|
||||
"Léon: The Pro!": "léon the pro",
|
||||
" A B ": "a b",
|
||||
"Привет, Мир": "привет мир",
|
||||
"Тёмный рыцарь": "темный рыцарь", // ё → е
|
||||
"ЁЖ": "еж",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
|
||||
@@ -31,7 +31,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
{
|
||||
"type": "movie" | "series",
|
||||
"title": "каноническое название",
|
||||
"original_title": "оригинальное название или пустая строка",
|
||||
"original_title": "оригинальное название (заполняй всегда, см. правила)",
|
||||
"year": число или 0,
|
||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||
"files": [
|
||||
@@ -47,6 +47,11 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Всегда заполняй И "title", И "original_title". Базы метаданных ищут прежде
|
||||
всего по оригинальному (обычно английскому) названию, поэтому "original_title"
|
||||
важен. Если отдельного оригинального названия нет или контент российского
|
||||
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
||||
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||
- Для фильма ровно один основной видеофайл role "main".
|
||||
|
||||
Reference in New Issue
Block a user