Добавил поиск метаданных по каталогам

This commit is contained in:
2026-06-14 15:21:01 +03:00
parent 9c1b178e46
commit 5087f35861
21 changed files with 1435 additions and 72 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
r := recognize.New(provider, recognize.Config{MaxRetries: 2}, log)
r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log)
const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/"
in := recognize.Input{
+118
View File
@@ -0,0 +1,118 @@
package recognize
import (
"context"
"strings"
"unicode"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
// если ровно один кандидат уверенно совпадает (название и год), возвращает
// матч с официальным id и каноническим именем. Несколько кандидатов или их
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
// валят распознавание — просто нет матча.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
if len(r.providers) == 0 {
return 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)
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
}
strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 {
continue
}
c := strong[0]
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year}
if mt == metadata.Series {
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
match.SeasonEpisodeCounts = counts
} else {
r.log.Warn("recognize: episode counts failed",
"provider", p.Name(), "id", c.ID, "err", cerr)
}
}
return match
}
return nil
}
// 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())
}
+155
View File
@@ -0,0 +1,155 @@
package recognize
import (
"context"
"errors"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
type fakeProvider struct {
name string
candidates []metadata.Candidate
counts map[int]int
searchErr error
searched int
}
func (f *fakeProvider) Name() string {
if f.name == "" {
return "tmdb"
}
return f.name
}
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
f.searched++
return f.candidates, f.searchErr
}
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
return f.counts, nil
}
func recognizerWith(p metadata.Provider) *Recognizer {
var providers []metadata.Provider
if p != nil {
providers = []metadata.Provider{p}
}
return New(&fakeLLM{}, providers, Config{}, testLogger())
}
func TestMatchMetadata_SingleStrong(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
}}
r := recognizerWith(p)
m := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
if m == nil {
t.Fatal("expected match")
}
if m.ProviderID != "603" || m.Provider != "tmdb" {
t.Errorf("match = %+v", m)
}
}
func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
// Два кандидата с тем же названием и годом — неоднозначно.
p := &fakeProvider{candidates: []metadata.Candidate{
{ID: "1", Title: "Fargo", Year: 2014},
{ID: "2", Title: "Fargo", Year: 2014},
}}
r := recognizerWith(p)
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_YearMismatch(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
r := recognizerWith(p)
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)
}
}
func TestMatchMetadata_OriginalTitle(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
}}
r := recognizerWith(p)
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)
}
}
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
p := &fakeProvider{
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
counts: map[int]int{1: 10, 2: 10},
}
r := recognizerWith(p)
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)
}
}
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
p := &fakeProvider{searchErr: errors.New("upstream down")}
r := recognizerWith(p)
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)
}
}
func TestMatchMetadata_Disabled(t *testing.T) {
r := recognizerWith(nil)
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
t.Errorf("no providers → no match, got %+v", m)
}
}
func TestNormalize(t *testing.T) {
cases := map[string]string{
"The Matrix": "the matrix",
"Léon: The Pro!": "léon the pro",
" A B ": "a b",
"Привет, Мир": "привет мир",
}
for in, want := range cases {
if got := normalize(in); got != want {
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
func TestRecognize_AutoWithMatch(t *testing.T) {
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
resp := `{"type":"movie","title":"The Matrix","year":1999,"confidence":0.95,
"provider_hint":"The Matrix","files":[{"src":"m/film.mkv","role":"main"}]}`
llmFake := &fakeLLM{responses: []string{resp}}
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
}}
r := New(llmFake, []metadata.Provider{p}, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
if !res.Decision.Auto {
t.Errorf("expected auto, reasons: %v", res.Decision.Reasons)
}
if res.Match == nil || res.Match.ProviderID != "603" {
t.Errorf("match = %+v", res.Match)
}
}
+51 -16
View File
@@ -4,14 +4,16 @@
// Конвейер (см. docs/specs/recognition.md):
// 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия;
// 2. вызов LLM со структурированным выводом → план в нашей схеме;
// 3. валидация плана в Go (схема + структура + согласованность сигналов);
// 4. решение «авто или review».
// 3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч
// по названию+году даёт официальный id и каноническое имя;
// 4. решение «авто или review»: авто только при подтверждённом матче,
// чистой структурной валидации (для сериала — число серий бьётся с
// базой), согласованности с пред-парсом и уверенности не ниже порога.
//
// Ф2 не сверяется с метабазами (TMDB/TVDB — Ф4) и ничего не пишет на диск:
// без подтверждённого матча в базе авто-раскладка не делается, поэтому в
// этой фазе решение всегда «review». Выход LLM недоверенный — план
// принимается только если каждый files[].src совпадает с реальным файлом
// торрента; итоговая безопасность пути держится на раскладке (Ф3).
// Без включённых баз (или без матча) авто-раскладка не делается — задача
// уходит в review. Выход LLM недоверенный: план принимается только если
// каждый files[].src совпадает с реальным файлом торрента; итоговая
// безопасность пути держится на раскладке (layout).
package recognize
import (
@@ -20,6 +22,7 @@ import (
"log/slog"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// MediaType — вид контента.
@@ -101,11 +104,21 @@ type Decision struct {
Reasons []string // причины ухода в review / предупреждения валидации
}
// Match — подтверждение распознавания базой метаданных.
type Match struct {
Provider string // "tmdb" | "tvdb"
ProviderID string // официальный id
Title string // каноническое название
Year int // каноничный год
SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
}
// Result — итог распознавания.
type Result struct {
Plan Plan
PreParse PreParse
Decision Decision
Match *Match // подтверждённый матч в базе (nil — нет)
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
}
@@ -117,27 +130,32 @@ type LLM interface {
// Config — параметры распознавания.
type Config struct {
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
MaxTokens int // лимит ответа модели (0 — дефолт)
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
MaxTokens int // лимит ответа модели (0 — дефолт)
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85)
}
const (
defaultMaxTokens = 4000
defaultMaxFiles = 100
defaultMaxTokens = 4000
defaultMaxFiles = 100
defaultAutoThreshold = 0.85
)
// Recognizer — реализация распознавания.
type Recognizer struct {
llm LLM
providers []metadata.Provider
maxRetry int
maxTokens int
maxFiles int
threshold float64
log *slog.Logger
}
// New собирает распознаватель.
func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
// New собирает распознаватель. providers — включённые базы метаданных
// (пусто → сверки нет, авто-раскладка не делается).
func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logger) *Recognizer {
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultMaxTokens
@@ -150,11 +168,17 @@ func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
if retries < 0 {
retries = 0
}
threshold := cfg.AutoThreshold
if threshold <= 0 {
threshold = defaultAutoThreshold
}
return &Recognizer{
llm: provider,
providers: providers,
maxRetry: retries,
maxTokens: maxTokens,
maxFiles: maxFiles,
threshold: threshold,
log: log,
}
}
@@ -209,15 +233,26 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
}, nil
}
dec := decide(plan, pre)
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
// в плане заменяем на каноничные.
match := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title
if match.Year != 0 {
plan.Year = match.Year
}
}
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
r.log.Info("recognize: done",
"type", plan.Type, "title", plan.Title, "year", plan.Year,
"files", len(plan.Files), "attempts", attempts,
"auto", dec.Auto, "reasons", len(dec.Reasons))
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{
Plan: plan,
PreParse: pre,
Decision: dec,
Match: match,
Attempts: attempts,
Raw: raw,
}, nil
+26 -12
View File
@@ -56,7 +56,7 @@ func TestRecognize_Movie(t *testing.T) {
{"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null}
]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, Config{MaxRetries: 2}, testLogger())
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
@@ -74,9 +74,10 @@ func TestRecognize_Movie(t *testing.T) {
if len(res.Decision.Reasons) == 0 {
t.Error("expected at least the no-DB-match reason")
}
// Чистая структура: единственная причина — отсутствие матча в базе.
if len(res.Decision.Reasons) != 1 {
t.Errorf("unexpected extra warnings: %v", res.Decision.Reasons)
// Чистая структура + уверенность 0.9 ≥ порога: единственная причина —
// отсутствие матча в базе.
if len(res.Decision.Reasons) != 1 || !hasReason(res.Decision.Reasons, "метабазы отключены") {
t.Errorf("unexpected reasons: %v", res.Decision.Reasons)
}
}
@@ -96,7 +97,7 @@ func TestRecognize_Series(t *testing.T) {
{"src":"Avatar/03.mkv","role":"episode","season":2,"episode":3}
]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, Config{}, testLogger())
r := New(f, nil, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
@@ -105,9 +106,22 @@ func TestRecognize_Series(t *testing.T) {
if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 {
t.Errorf("plan = %+v", res.Plan)
}
if len(res.Decision.Reasons) != 1 {
t.Errorf("clean series should warn only about DB match, got: %v", res.Decision.Reasons)
// Метабазы выключены → авто нет; причина про базу обязательна.
if res.Decision.Auto {
t.Error("auto must be false without metadata providers")
}
if !hasReason(res.Decision.Reasons, "метабазы отключены") {
t.Errorf("expected metadata-off reason, got: %v", res.Decision.Reasons)
}
}
func hasReason(reasons []string, substr string) bool {
for _, r := range reasons {
if strings.Contains(r, substr) {
return true
}
}
return false
}
func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
@@ -120,7 +134,7 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
good := `{"type":"movie","title":"Some Movie","year":2020,"files":[
{"src":"movie/film.mkv","role":"main"}]}`
f := &fakeLLM{responses: []string{bad, good}}
r := New(f, Config{MaxRetries: 2}, testLogger())
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
@@ -143,7 +157,7 @@ func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
bad := `not a json at all`
f := &fakeLLM{responses: []string{bad}}
r := New(f, Config{MaxRetries: 2}, testLogger())
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
@@ -167,7 +181,7 @@ func TestRecognize_TransportErrorPropagates(t *testing.T) {
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
wantErr := errors.New("connection refused")
f := &fakeLLM{errs: []error{wantErr}}
r := New(f, Config{MaxRetries: 2}, testLogger())
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
_, err := r.Recognize(context.Background(), in)
if err == nil || !errors.Is(err, wantErr) {
@@ -188,7 +202,7 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
resp := `{"type":"series","title":"Some Show","files":[
{"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, Config{}, testLogger())
r := New(f, nil, Config{}, testLogger())
if _, err := r.Recognize(context.Background(), in); err != nil {
t.Fatalf("Recognize: %v", err)
}
@@ -219,7 +233,7 @@ func TestRecognize_FileListTruncated(t *testing.T) {
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
`","role":"episode","season":1,"episode":1}]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, Config{MaxFiles: 100}, testLogger())
r := New(f, nil, Config{MaxFiles: 100}, testLogger())
if _, err := r.Recognize(context.Background(), in); err != nil {
t.Fatalf("Recognize: %v", err)
}
+64 -7
View File
@@ -76,15 +76,72 @@ func validateSchema(p *Plan, in Input) error {
return nil
}
// decide считает решение модели уверенности. В Ф2 метабазы выключены, а без
// подтверждённого матча в базе авто-раскладка не делается (recognition.md),
// поэтому Auto всегда false; здесь же копим структурные предупреждения и
// расхождения с пред-парсом — они объясняют ревью человеку.
func decide(p Plan, pre PreParse) Decision {
reasons := []string{"матч в базе не подтверждён (метабазы отключены в Ф2) → review"}
// decide считает решение модели уверенности (см. recognition.md). Авто —
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
// структурная валидация (для сериала — число серий бьётся с базой);
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
// невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
var reasons []string
switch {
case !metadataEnabled:
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
case match == nil:
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
}
reasons = append(reasons, structuralWarnings(p)...)
if match != nil && p.Type == MediaSeries {
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
}
reasons = append(reasons, consistencyWarnings(p, pre)...)
return Decision{Auto: false, Reasons: reasons}
if p.Confidence < threshold {
reasons = append(reasons,
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
}
return Decision{Auto: len(reasons) == 0, Reasons: reasons}
}
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
// Нет данных по сезону → блокируем авто (полноту пака не подтвердить).
func episodeCountWarnings(p Plan, counts map[int]int) []string {
recognized := map[int]int{}
for _, f := range p.Files {
if f.Role == RoleEpisode && f.Episode != nil {
season := 0
if f.Season != nil {
season = *f.Season
}
recognized[season]++
}
}
var w []string
for _, season := range sortedKeys(toSlices(recognized)) {
rc := recognized[season]
dbc, ok := counts[season]
switch {
case !ok || dbc == 0:
w = append(w, fmt.Sprintf("сезон %d: в базе нет данных о числе серий", season))
case rc != dbc:
w = append(w, fmt.Sprintf("сезон %d: распознано серий %d, в базе %d", season, rc, dbc))
}
}
return w
}
// toSlices превращает map[int]int в map[int][]int для sortedKeys (нужны
// только ключи).
func toSlices(m map[int]int) map[int][]int {
out := make(map[int][]int, len(m))
for k := range m {
out[k] = nil
}
return out
}
// structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор).
+58 -4
View File
@@ -155,17 +155,71 @@ func TestConsistencyWarnings(t *testing.T) {
}
}
func TestDecide_AlwaysReview(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{})
func TestDecide_MetadataDisabled(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, false, 0.85)
if d.Auto {
t.Error("Ф2 decision must never be auto")
t.Error("без метабаз авто недопустимо")
}
if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") {
t.Errorf("first reason should be DB match, got %v", d.Reasons)
}
}
func TestDecide_NoMatch(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, true, 0.85)
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
t.Errorf("reasons = %v", d.Reasons)
}
}
func TestDecide_AutoMovie(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
if !d.Auto {
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
}
}
func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
Files: []PlanFile{{Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
d := decide(p, PreParse{}, match, true, 0.85)
if d.Auto || !hasReason(d.Reasons, "уверенность") {
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
}
}
func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
s := 2
mk := func(e int) PlanFile { ep := e; return PlanFile{Role: RoleEpisode, Season: &s, Episode: &ep} }
p := Plan{Type: MediaSeries, Title: "Fargo", Year: 2014, Confidence: 0.9,
Files: []PlanFile{mk(1), mk(2), mk(3)}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
SeasonEpisodeCounts: map[int]int{2: 3}}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
}
// Неполный пак (в базе 10) — авто блокируется.
match.SeasonEpisodeCounts = map[int]int{2: 10}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
}
// Нет данных о числе серий — авто блокируется.
match.SeasonEpisodeCounts = nil
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
!hasReason(d.Reasons, "нет данных о числе серий") {
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
}
}
func TestPreParse(t *testing.T) {
pre := preParse("The.Matrix.1999.1080p.BluRay.x264")
if pre.Year != 1999 {