From 5087f35861fb65a1913467f7b13db2f459532055 Mon Sep 17 00:00:00 2001
From: Anton Vakhrushev
Date: Sun, 14 Jun 2026 15:21:01 +0300
Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4?=
=?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=20=D0=BA=D0=B0?=
=?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
cmd/jellybit/serve.go | 46 +++++-
internal/httpapi/review.go | 6 +
internal/metadata/doc.go | 4 -
internal/metadata/http.go | 89 ++++++++++++
internal/metadata/integration_test.go | 53 +++++++
internal/metadata/metadata.go | 45 ++++++
internal/metadata/tmdb.go | 132 ++++++++++++++++++
internal/metadata/tmdb_test.go | 109 +++++++++++++++
internal/metadata/tvdb.go | 185 +++++++++++++++++++++++++
internal/metadata/tvdb_test.go | 132 ++++++++++++++++++
internal/recognize/integration_test.go | 2 +-
internal/recognize/metadata.go | 118 ++++++++++++++++
internal/recognize/metadata_test.go | 155 +++++++++++++++++++++
internal/recognize/recognize.go | 67 ++++++---
internal/recognize/recognize_test.go | 38 +++--
internal/recognize/validate.go | 71 +++++++++-
internal/recognize/validate_test.go | 62 ++++++++-
internal/worker/review.go | 112 +++++++++++----
internal/worker/review_test.go | 79 ++++++++++-
web/templates/review.html | 1 +
21 files changed, 1435 insertions(+), 72 deletions(-)
delete mode 100644 internal/metadata/doc.go
create mode 100644 internal/metadata/http.go
create mode 100644 internal/metadata/integration_test.go
create mode 100644 internal/metadata/metadata.go
create mode 100644 internal/metadata/tmdb.go
create mode 100644 internal/metadata/tmdb_test.go
create mode 100644 internal/metadata/tvdb.go
create mode 100644 internal/metadata/tvdb_test.go
create mode 100644 internal/recognize/metadata.go
create mode 100644 internal/recognize/metadata_test.go
diff --git a/.gitignore b/.gitignore
index 8e1eece..6dfdb09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
# Реальный конфиг (секреты) и локальная БД
/config.toml
+/.env
*.db
*.db-wal
*.db-shm
diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go
index b1f43af..3d52cad 100644
--- a/cmd/jellybit/serve.go
+++ b/cmd/jellybit/serve.go
@@ -16,6 +16,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logging"
+ "git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -60,6 +61,15 @@ func runServe(args []string) error {
SavePath: cfg.QBittorrent.SavePath,
}, logger)
+ // Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
+ providers, err := metadataProviders(cfg)
+ if err != nil {
+ return err
+ }
+ for _, p := range providers {
+ logger.Info("metadata provider enabled", "provider", p.Name())
+ }
+
// Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован,
// сервис работает как в Ф1 (completed-задачи дальше не двигаются).
var recognizer worker.Recognizer
@@ -75,8 +85,11 @@ func runServe(args []string) error {
if perr != nil {
return fmt.Errorf("llm provider: %w", perr)
}
- recognizer = recognize.New(provider, recognize.Config{MaxRetries: cfg.LLM.MaxRetries}, logger)
- logger.Info("recognizer ready", "model", cfg.LLM.Model)
+ recognizer = recognize.New(provider, providers, recognize.Config{
+ MaxRetries: cfg.LLM.MaxRetries,
+ AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
+ }, logger)
+ logger.Info("recognizer ready", "model", cfg.LLM.Model, "providers", len(providers))
} else {
logger.Warn("llm not configured, recognition disabled")
}
@@ -142,3 +155,32 @@ func runServe(args []string) error {
logger.Info("stopped")
return nil
}
+
+// metadataProviders собирает включённые конфигом базы метаданных. Для
+// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
+func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) {
+ var out []metadata.Provider
+ if cfg.Metadata.TVDB.Enabled {
+ p, err := metadata.NewTVDB(metadata.TVDBConfig{
+ APIKey: cfg.Metadata.TVDB.APIKey,
+ Proxy: cfg.Metadata.TVDB.Proxy,
+ Timeout: cfg.Metadata.TVDB.Timeout.Std(),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("tvdb provider: %w", err)
+ }
+ out = append(out, p)
+ }
+ if cfg.Metadata.TMDB.Enabled {
+ p, err := metadata.NewTMDB(metadata.TMDBConfig{
+ APIKey: cfg.Metadata.TMDB.APIKey,
+ Proxy: cfg.Metadata.TMDB.Proxy,
+ Timeout: cfg.Metadata.TMDB.Timeout.Std(),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("tmdb provider: %w", err)
+ }
+ out = append(out, p)
+ }
+ return out, nil
+}
diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go
index b4581a3..f7a6728 100644
--- a/internal/httpapi/review.go
+++ b/internal/httpapi/review.go
@@ -36,6 +36,8 @@ type reviewView struct {
Title string
OriginalTitle string
Year int
+ Provider string
+ ProviderID string
Confidence string
Reasons []string
Hints []string
@@ -85,6 +87,10 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
view.Reasons = rec.ReasonList()
+ if rec.Provider.Valid && rec.Provider.String != "none" {
+ view.Provider = rec.Provider.String
+ view.ProviderID = rec.ProviderID.String
+ }
if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
}
diff --git a/internal/metadata/doc.go b/internal/metadata/doc.go
deleted file mode 100644
index 9c1c39a..0000000
--- a/internal/metadata/doc.go
+++ /dev/null
@@ -1,4 +0,0 @@
-// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
-//
-// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
-package metadata
diff --git a/internal/metadata/http.go b/internal/metadata/http.go
new file mode 100644
index 0000000..8561071
--- /dev/null
+++ b/internal/metadata/http.go
@@ -0,0 +1,89 @@
+package metadata
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+const defaultTimeout = 10 * time.Second
+
+// newHTTPClient собирает http.Client с опциональным прокси и таймаутом.
+func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
+ if timeout <= 0 {
+ timeout = defaultTimeout
+ }
+ transport := http.DefaultTransport
+ if proxy != "" {
+ u, err := url.Parse(proxy)
+ if err != nil {
+ return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
+ }
+ transport = &http.Transport{Proxy: http.ProxyURL(u)}
+ }
+ return &http.Client{Timeout: timeout, Transport: transport}, nil
+}
+
+const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
+
+// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
+// дополнительные заголовки (напр. Authorization).
+func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[string]string, out any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return fmt.Errorf("metadata: build request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ return doJSON(hc, req, out)
+}
+
+// postJSON выполняет POST с JSON-телом и декодирует ответ.
+func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any) error {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("metadata: marshal body: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
+ if err != nil {
+ return fmt.Errorf("metadata: build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ return doJSON(hc, req, out)
+}
+
+func doJSON(hc *http.Client, req *http.Request, out any) error {
+ resp, err := hc.Do(req)
+ if err != nil {
+ return fmt.Errorf("metadata: request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
+ if err != nil {
+ return fmt.Errorf("metadata: read body: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
+ }
+ return nil
+}
+
+func snippet(b []byte) string {
+ const max = 200
+ if len(b) > max {
+ return string(b[:max]) + "…"
+ }
+ return string(b)
+}
diff --git a/internal/metadata/integration_test.go b/internal/metadata/integration_test.go
new file mode 100644
index 0000000..8ae0052
--- /dev/null
+++ b/internal/metadata/integration_test.go
@@ -0,0 +1,53 @@
+package metadata_test
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "git.vakhrushev.me/av/jellybit/internal/metadata"
+)
+
+// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
+// пропускается; включается ключом:
+//
+// TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v
+func TestIntegration_TVDB(t *testing.T) {
+ key := os.Getenv("TVDB_API_KEY")
+ if key == "" {
+ t.Skip("set TVDB_API_KEY to run")
+ }
+ c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second})
+ if err != nil {
+ t.Fatalf("NewTVDB: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014})
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ t.Logf("candidates (%d):", len(cands))
+ for i, cd := range cands {
+ if i >= 5 {
+ break
+ }
+ t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year)
+ }
+ if len(cands) == 0 {
+ t.Fatal("ожидался хотя бы один кандидат для Fargo")
+ }
+
+ // Берём первого с непустым id и тянем число серий по сезонам.
+ id := cands[0].ID
+ counts, err := c.SeasonEpisodeCounts(ctx, id)
+ if err != nil {
+ t.Fatalf("SeasonEpisodeCounts(%s): %v", id, err)
+ }
+ t.Logf("season episode counts for id=%s: %v", id, counts)
+ if len(counts) == 0 {
+ t.Error("ожидались данные о числе серий по сезонам")
+ }
+}
diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go
new file mode 100644
index 0000000..61913c4
--- /dev/null
+++ b/internal/metadata/metadata.go
@@ -0,0 +1,45 @@
+// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB.
+//
+// Базы опциональны (включаются конфигом). Их роль — подтвердить распознавание
+// официальным id и каноническим именем: при единичном сильном матче по
+// названию+году раскладка делается автоматически, иначе уходит в review
+// (см. docs/specs/recognition.md → «Модель уверенности»). Каждый клиент
+// ходит наружу через опциональный HTTP-прокси с таймаутом.
+package metadata
+
+import "context"
+
+// MediaType — вид контента в запросе к базе.
+type MediaType string
+
+const (
+ Movie MediaType = "movie"
+ Series MediaType = "series"
+)
+
+// Query — запрос поиска в базе.
+type Query struct {
+ Type MediaType
+ Title string // каноническое название или provider_hint
+ Year int // 0 — без ограничения по году
+}
+
+// Candidate — результат поиска: официальный id и каноническое имя.
+type Candidate struct {
+ Provider string // "tmdb" | "tvdb"
+ ID string
+ Title string
+ OriginalTitle string
+ Year int
+}
+
+// Provider — одна база метаданных.
+type Provider interface {
+ // Name — идентификатор провайдера ("tmdb"/"tvdb"), он же префикс тега.
+ Name() string
+ // Search ищет кандидатов по названию (и году, если задан).
+ Search(ctx context.Context, q Query) ([]Candidate, error)
+ // SeasonEpisodeCounts возвращает число серий по сезонам для сериала
+ // (ключ — номер сезона). Нужен для валидации полноты сезон-пака.
+ SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error)
+}
diff --git a/internal/metadata/tmdb.go b/internal/metadata/tmdb.go
new file mode 100644
index 0000000..3a9f37b
--- /dev/null
+++ b/internal/metadata/tmdb.go
@@ -0,0 +1,132 @@
+package metadata
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const tmdbDefaultBaseURL = "https://api.themoviedb.org/3"
+
+// TMDBConfig — настройки клиента TMDB.
+type TMDBConfig struct {
+ APIKey string
+ Proxy string
+ Timeout time.Duration
+ BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
+}
+
+// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
+type TMDB struct {
+ apiKey string
+ baseURL string
+ hc *http.Client
+}
+
+// NewTMDB собирает клиент TMDB.
+func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
+ if cfg.APIKey == "" {
+ return nil, fmt.Errorf("metadata: tmdb api_key required")
+ }
+ hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
+ if err != nil {
+ return nil, err
+ }
+ base := cfg.BaseURL
+ if base == "" {
+ base = tmdbDefaultBaseURL
+ }
+ return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
+}
+
+func (t *TMDB) Name() string { return "tmdb" }
+
+type tmdbSearchResp struct {
+ Results []struct {
+ ID int `json:"id"`
+ Title string `json:"title"` // movie
+ OriginalTitle string `json:"original_title"` // movie
+ Name string `json:"name"` // tv
+ OriginalName string `json:"original_name"` // tv
+ ReleaseDate string `json:"release_date"` // movie
+ FirstAirDate string `json:"first_air_date"` // tv
+ } `json:"results"`
+}
+
+// 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"}}
+ switch q.Type {
+ case Movie:
+ path = "/search/movie"
+ if q.Year > 0 {
+ params.Set("year", strconv.Itoa(q.Year))
+ }
+ case Series:
+ path = "/search/tv"
+ if q.Year > 0 {
+ params.Set("first_air_date_year", strconv.Itoa(q.Year))
+ }
+ default:
+ return nil, fmt.Errorf("metadata: tmdb: неизвестный тип %q", q.Type)
+ }
+
+ var resp tmdbSearchResp
+ if err := getJSON(ctx, t.hc, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
+ return nil, fmt.Errorf("tmdb search: %w", err)
+ }
+
+ out := make([]Candidate, 0, len(resp.Results))
+ for _, r := range resp.Results {
+ title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
+ if q.Type == Series {
+ title, orig, date = r.Name, r.OriginalName, r.FirstAirDate
+ }
+ out = append(out, Candidate{
+ Provider: "tmdb",
+ ID: strconv.Itoa(r.ID),
+ Title: title,
+ OriginalTitle: orig,
+ Year: yearOf(date),
+ })
+ }
+ return out, nil
+}
+
+type tmdbTVResp struct {
+ Seasons []struct {
+ SeasonNumber int `json:"season_number"`
+ EpisodeCount int `json:"episode_count"`
+ } `json:"seasons"`
+}
+
+// SeasonEpisodeCounts возвращает число серий по сезонам сериала.
+func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
+ params := url.Values{"api_key": {t.apiKey}}
+ var resp tmdbTVResp
+ if err := getJSON(ctx, t.hc, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
+ return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
+ }
+ out := make(map[int]int, len(resp.Seasons))
+ for _, s := range resp.Seasons {
+ out[s.SeasonNumber] = s.EpisodeCount
+ }
+ return out, nil
+}
+
+// yearOf достаёт год из даты вида "1999-03-31".
+func yearOf(date string) int {
+ if len(date) < 4 {
+ return 0
+ }
+ y, err := strconv.Atoi(date[:4])
+ if err != nil {
+ return 0
+ }
+ return y
+}
diff --git a/internal/metadata/tmdb_test.go b/internal/metadata/tmdb_test.go
new file mode 100644
index 0000000..78e91af
--- /dev/null
+++ b/internal/metadata/tmdb_test.go
@@ -0,0 +1,109 @@
+package metadata
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func newTMDB(t *testing.T, url string) *TMDB {
+ t.Helper()
+ c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url})
+ if err != nil {
+ t.Fatalf("NewTMDB: %v", err)
+ }
+ return c
+}
+
+func TestTMDB_SearchMovie(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/search/movie" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ q := r.URL.Query()
+ if q.Get("api_key") != "k" || q.Get("query") != "The Matrix" || q.Get("year") != "1999" {
+ t.Errorf("query = %v", q)
+ }
+ _, _ = w.Write([]byte(`{"results":[
+ {"id":603,"title":"The Matrix","original_title":"The Matrix","release_date":"1999-03-31"}
+ ]}`))
+ }))
+ defer srv.Close()
+
+ got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Matrix", Year: 1999})
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d candidates", len(got))
+ }
+ c := got[0]
+ if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
+ t.Errorf("candidate = %+v", c)
+ }
+}
+
+func TestTMDB_SearchSeries(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/search/tv" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ if r.URL.Query().Get("first_air_date_year") != "2015" {
+ t.Errorf("year param = %q", r.URL.Query().Get("first_air_date_year"))
+ }
+ _, _ = w.Write([]byte(`{"results":[
+ {"id":60622,"name":"Fargo","original_name":"Fargo","first_air_date":"2014-04-15"}
+ ]}`))
+ }))
+ defer srv.Close()
+
+ got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2015})
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 {
+ t.Errorf("candidate = %+v", got[0])
+ }
+}
+
+func TestTMDB_SeasonEpisodeCounts(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/tv/60622" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"seasons":[
+ {"season_number":0,"episode_count":2},
+ {"season_number":1,"episode_count":10},
+ {"season_number":2,"episode_count":10}
+ ]}`))
+ }))
+ defer srv.Close()
+
+ counts, err := newTMDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "60622")
+ if err != nil {
+ t.Fatalf("SeasonEpisodeCounts: %v", err)
+ }
+ if counts[1] != 10 || counts[2] != 10 || counts[0] != 2 {
+ t.Errorf("counts = %v", counts)
+ }
+}
+
+func TestTMDB_ErrorStatus(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"status_message":"invalid key"}`))
+ }))
+ defer srv.Close()
+
+ _, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"})
+ if err == nil {
+ t.Fatal("want error on 401")
+ }
+}
+
+func TestNewTMDB_RequiresKey(t *testing.T) {
+ if _, err := NewTMDB(TMDBConfig{}); err == nil {
+ t.Fatal("want error without api_key")
+ }
+}
diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go
new file mode 100644
index 0000000..707e15a
--- /dev/null
+++ b/internal/metadata/tvdb.go
@@ -0,0 +1,185 @@
+package metadata
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const tvdbDefaultBaseURL = "https://api4.thetvdb.com/v4"
+
+// TVDBConfig — настройки клиента TheTVDB.
+type TVDBConfig struct {
+ APIKey string
+ Proxy string
+ Timeout time.Duration
+ BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах
+}
+
+// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и
+// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
+// живым API v4 (см. integration_test.go).
+type TVDB struct {
+ apiKey string
+ baseURL string
+ hc *http.Client
+
+ mu sync.Mutex
+ token string
+}
+
+// NewTVDB собирает клиент TVDB.
+func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
+ if cfg.APIKey == "" {
+ return nil, fmt.Errorf("metadata: tvdb api_key required")
+ }
+ hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
+ if err != nil {
+ return nil, err
+ }
+ base := cfg.BaseURL
+ if base == "" {
+ base = tvdbDefaultBaseURL
+ }
+ return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
+}
+
+func (t *TVDB) Name() string { return "tvdb" }
+
+// login получает и кэширует bearer-токен.
+func (t *TVDB) login(ctx context.Context) (string, error) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.token != "" {
+ return t.token, nil
+ }
+ var resp struct {
+ Data struct {
+ Token string `json:"token"`
+ } `json:"data"`
+ }
+ if err := postJSON(ctx, t.hc, t.baseURL+"/login",
+ map[string]string{"apikey": t.apiKey}, &resp); err != nil {
+ return "", fmt.Errorf("tvdb login: %w", err)
+ }
+ if resp.Data.Token == "" {
+ return "", fmt.Errorf("tvdb login: empty token")
+ }
+ t.token = resp.Data.Token
+ return t.token, nil
+}
+
+// get делает авторизованный GET; при 401 один раз перелогинивается.
+func (t *TVDB) get(ctx context.Context, path string, out any) error {
+ token, err := t.login(ctx)
+ if err != nil {
+ return err
+ }
+ status, raw, err := t.rawGet(ctx, path, token)
+ if err != nil {
+ return err
+ }
+ if status == http.StatusUnauthorized {
+ t.mu.Lock()
+ t.token = "" // сбрасываем протухший токен
+ t.mu.Unlock()
+ if token, err = t.login(ctx); err != nil {
+ return err
+ }
+ if status, raw, err = t.rawGet(ctx, path, token); err != nil {
+ return err
+ }
+ }
+ if status != http.StatusOK {
+ return fmt.Errorf("tvdb: status %d: %s", status, snippet(raw))
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("tvdb: decode: %w (body: %s)", err, snippet(raw))
+ }
+ return nil
+}
+
+func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.baseURL+path, nil)
+ if err != nil {
+ return 0, nil, fmt.Errorf("tvdb: build request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ resp, err := t.hc.Do(req)
+ if err != nil {
+ return 0, nil, fmt.Errorf("tvdb: request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
+ if err != nil {
+ return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
+ }
+ return resp.StatusCode, raw, nil
+}
+
+type tvdbSearchResp struct {
+ Data []struct {
+ TVDBID string `json:"tvdb_id"`
+ Name string `json:"name"`
+ Year string `json:"year"`
+ } `json:"data"`
+}
+
+// Search ищет сериал/фильм по названию и году.
+func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
+ typ := "series"
+ if q.Type == Movie {
+ typ = "movie"
+ }
+ params := url.Values{"query": {q.Title}, "type": {typ}}
+ if q.Year > 0 {
+ params.Set("year", strconv.Itoa(q.Year))
+ }
+ var resp tvdbSearchResp
+ if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
+ return nil, fmt.Errorf("tvdb search: %w", err)
+ }
+ out := make([]Candidate, 0, len(resp.Data))
+ for _, r := range resp.Data {
+ if r.TVDBID == "" {
+ continue
+ }
+ year, _ := strconv.Atoi(r.Year)
+ out = append(out, Candidate{
+ Provider: "tvdb",
+ ID: r.TVDBID,
+ Title: r.Name,
+ Year: year,
+ })
+ }
+ return out, nil
+}
+
+type tvdbExtendedResp struct {
+ Data struct {
+ Episodes []struct {
+ SeasonNumber int `json:"seasonNumber"`
+ } `json:"episodes"`
+ } `json:"data"`
+}
+
+// SeasonEpisodeCounts считает число серий по сезонам из расширенных данных.
+func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
+ var resp tvdbExtendedResp
+ if err := t.get(ctx, "/series/"+url.PathEscape(id)+"/extended?meta=episodes&short=true", &resp); err != nil {
+ return nil, fmt.Errorf("tvdb series %s: %w", id, err)
+ }
+ out := map[int]int{}
+ for _, e := range resp.Data.Episodes {
+ out[e.SeasonNumber]++
+ }
+ return out, nil
+}
diff --git a/internal/metadata/tvdb_test.go b/internal/metadata/tvdb_test.go
new file mode 100644
index 0000000..ef6c330
--- /dev/null
+++ b/internal/metadata/tvdb_test.go
@@ -0,0 +1,132 @@
+package metadata
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+)
+
+// fakeTVDB — стенд v4: /login выдаёт токен, остальное требует Bearer.
+func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]string
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ if body["apikey"] != "k" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ if logins != nil {
+ logins.Add(1)
+ }
+ _, _ = w.Write([]byte(`{"status":"success","data":{"token":"tok"}}`))
+ })
+ authed := func(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+ }
+ mux.HandleFunc("/search", authed(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" {
+ t.Errorf("query = %v", r.URL.Query())
+ }
+ _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`))
+ }))
+ mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"data":{"episodes":[
+ {"seasonNumber":1},{"seasonNumber":1},{"seasonNumber":2}
+ ]}}`))
+ }))
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func newTVDB(t *testing.T, url string) *TVDB {
+ t.Helper()
+ c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url})
+ if err != nil {
+ t.Fatalf("NewTVDB: %v", err)
+ }
+ return c
+}
+
+func TestTVDB_SearchAndLoginCached(t *testing.T) {
+ var logins atomic.Int32
+ srv := fakeTVDB(t, &logins)
+ c := newTVDB(t, srv.URL)
+
+ got, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014})
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 {
+ t.Fatalf("candidate = %+v", got)
+ }
+ // Второй запрос переиспользует токен — повторного логина нет.
+ if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil {
+ t.Fatal(err)
+ }
+ if logins.Load() != 1 {
+ t.Errorf("logins = %d, want 1 (token cached)", logins.Load())
+ }
+}
+
+func TestTVDB_SeasonEpisodeCounts(t *testing.T) {
+ srv := fakeTVDB(t, nil)
+ counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
+ if err != nil {
+ t.Fatalf("SeasonEpisodeCounts: %v", err)
+ }
+ if counts[1] != 2 || counts[2] != 1 {
+ t.Errorf("counts = %v", counts)
+ }
+}
+
+func TestTVDB_ReloginOn401(t *testing.T) {
+ var logins atomic.Int32
+ var token atomic.Value
+ token.Store("tok")
+ mux := http.NewServeMux()
+ mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) {
+ logins.Add(1)
+ _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`))
+ })
+ var firstCall atomic.Bool
+ mux.HandleFunc("/search", func(w http.ResponseWriter, _ *http.Request) {
+ // Первый авторизованный запрос отдаёт 401 (токен «протух»).
+ if firstCall.CompareAndSwap(false, true) {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ c := newTVDB(t, srv.URL)
+ got, err := c.Search(context.Background(), Query{Type: Series, Title: "X"})
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d", len(got))
+ }
+ if logins.Load() != 2 {
+ t.Errorf("logins = %d, want 2 (initial + relogin)", logins.Load())
+ }
+}
+
+func TestNewTVDB_RequiresKey(t *testing.T) {
+ if _, err := NewTVDB(TVDBConfig{}); err == nil {
+ t.Fatal("want error without api_key")
+ }
+}
diff --git a/internal/recognize/integration_test.go b/internal/recognize/integration_test.go
index a694b23..7c896f5 100644
--- a/internal/recognize/integration_test.go
+++ b/internal/recognize/integration_test.go
@@ -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{
diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go
new file mode 100644
index 0000000..486b4e7
--- /dev/null
+++ b/internal/recognize/metadata.go
@@ -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())
+}
diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go
new file mode 100644
index 0000000..35145ad
--- /dev/null
+++ b/internal/recognize/metadata_test.go
@@ -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)
+ }
+}
diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go
index 9deb2be..8e4dbca 100644
--- a/internal/recognize/recognize.go
+++ b/internal/recognize/recognize.go
@@ -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
diff --git a/internal/recognize/recognize_test.go b/internal/recognize/recognize_test.go
index c44bf61..4376099 100644
--- a/internal/recognize/recognize_test.go
+++ b/internal/recognize/recognize_test.go
@@ -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)
}
diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go
index 810e0a6..d7b9938 100644
--- a/internal/recognize/validate.go
+++ b/internal/recognize/validate.go
@@ -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 — нарушения структуры плана (мягкие, не блокируют разбор).
diff --git a/internal/recognize/validate_test.go b/internal/recognize/validate_test.go
index 83e53f3..6093546 100644
--- a/internal/recognize/validate_test.go
+++ b/internal/recognize/validate_test.go
@@ -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 {
diff --git a/internal/worker/review.go b/internal/worker/review.go
index c41f6e5..ba1ec7b 100644
--- a/internal/worker/review.go
+++ b/internal/worker/review.go
@@ -112,18 +112,25 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
-func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, _ string) {
+func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
planJSON, err := json.Marshal(res.Plan)
if err != nil {
w.log.Error("recognize: marshal plan", "download_id", id, "err", err)
planJSON = []byte("{}")
}
+ provider, providerID, tag := "none", "", ""
+ if res.Match != nil {
+ provider, providerID = res.Match.Provider, res.Match.ProviderID
+ tag = providerTag(res.Match.Provider, res.Match.ProviderID)
+ }
+
rec := &store.Recognition{
DownloadID: id,
MediaType: store.NullString(string(res.Plan.Type)),
Title: store.NullString(res.Plan.Title),
- Provider: store.NullString("none"),
+ Provider: store.NullString(provider),
+ ProviderID: store.NullString(providerID),
Plan: store.NullString(string(planJSON)),
RawLLM: store.NullString(res.Raw),
}
@@ -155,9 +162,31 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
w.log.Error("recognize: persist", "download_id", id, "err", err)
return
}
+
+ // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
+ // иначе — review. Раскладчик может быть не сконфигурирован.
+ if res.Decision.Auto && w.layouter != nil {
+ plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id))
+ w.transition(ctx, *d, store.StateLinking, "", "")
+ if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
+ w.log.Warn("recognize: auto-apply failed, left for review",
+ "download_id", id, "err", err)
+ }
+ return
+ }
w.transition(ctx, *d, store.StateReview, "", "")
}
+// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
+func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
+ o, err := w.store.ListOverrides(ctx, id)
+ if err != nil {
+ w.log.Warn("recognize: list overrides", "download_id", id, "err", err)
+ return nil
+ }
+ return o
+}
+
// --- Команды ревью ---
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
@@ -177,7 +206,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State)
}
- plan, err := w.effectivePlan(ctx, id)
+ plan, tag, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
@@ -186,9 +215,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: торрент не найден: %v", err)
}
- links, err := w.layouter.BuildLinks(toLayoutPlan(plan, t.SavePath))
+ w.transition(ctx, *d, store.StateLinking, "", "")
+ if err := w.linkPlan(ctx, d, plan, tag, t.SavePath); err != nil {
+ return fmt.Errorf("apply: %w", err)
+ }
+ return nil
+}
+
+// linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и
+// двигает задачу: done при успехе, review при коллизии/невалидном плане,
+// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu.
+func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error {
+ links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
if err != nil {
- return fmt.Errorf("apply: построение ссылок: %w", err)
+ w.transition(ctx, *d, store.StateReview, "build", err.Error())
+ return fmt.Errorf("построение ссылок: %w", err)
}
batch := w.newID()
@@ -198,7 +239,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
fl := make([]store.FileLink, 0, len(results))
for _, r := range results {
fl = append(fl, store.FileLink{
- DownloadID: id,
+ DownloadID: d.ID,
ApplyBatchID: batch,
SrcPath: r.Link.Src,
DstPath: r.Link.Dst,
@@ -208,21 +249,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
}
if len(fl) > 0 {
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
- return fmt.Errorf("apply: запись ссылок: %w", err)
+ return fmt.Errorf("запись ссылок: %w", err)
}
}
if applyErr != nil {
if errors.Is(applyErr, layout.ErrCollision) {
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
- return fmt.Errorf("apply: %w", applyErr)
+ return applyErr
}
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
- return fmt.Errorf("apply: %w", applyErr)
+ return applyErr
}
w.transition(ctx, *d, store.StateDone, "", "")
- w.log.Info("apply: linked", "download_id", id, "batch", batch, "links", len(fl))
+ w.log.Info("apply: linked", "download_id", d.ID, "batch", batch, "links", len(fl))
return nil
}
@@ -409,10 +450,11 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
plan = applyOverrides(plan, overrides)
rd.Plan = plan
- // Превью строим по относительным путям; ошибку игнорируем —
- // просто покажем причины без превью.
+ // Превью строим по относительным путям с provider-тегом; ошибку
+ // игнорируем — просто покажем причины без превью.
if w.layouter != nil {
- if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "")); lerr == nil {
+ tag := providerTag(rec.Provider.String, rec.ProviderID.String)
+ if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
rd.Preview = links
}
}
@@ -421,24 +463,26 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
return rd, nil
}
-// effectivePlan загружает текущий план и применяет правки (под mu).
-func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, error) {
+// effectivePlan загружает текущий план, применяет правки и возвращает
+// provider-тег для имени папки (под mu).
+func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
- return recognize.Plan{}, err
+ return recognize.Plan{}, "", err
}
if rec == nil || !rec.Plan.Valid {
- return recognize.Plan{}, fmt.Errorf("нет плана распознавания")
+ return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания")
}
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
- return recognize.Plan{}, fmt.Errorf("разбор плана: %w", err)
+ return recognize.Plan{}, "", fmt.Errorf("разбор плана: %w", err)
}
overrides, err := w.store.ListOverrides(ctx, id)
if err != nil {
- return recognize.Plan{}, err
+ return recognize.Plan{}, "", err
}
- return applyOverrides(plan, overrides), nil
+ tag := providerTag(rec.Provider.String, rec.ProviderID.String)
+ return applyOverrides(plan, overrides), tag, nil
}
// --- Хелперы преобразования ---
@@ -460,14 +504,32 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
return plan
}
+// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
+// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
+func providerTag(provider, id string) string {
+ if id == "" {
+ return ""
+ }
+ switch provider {
+ case "tmdb":
+ return "tmdbid-" + id
+ case "tvdb":
+ return "tvdbid-" + id
+ default:
+ return ""
+ }
+}
+
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
-// относительные (для превью). Роли вне main/episode/subtitle отбрасываются.
-func toLayoutPlan(plan recognize.Plan, srcPrefix string) layout.Plan {
+// относительные (для превью). providerTag добавляется к имени папки. Роли
+// вне main/episode/subtitle отбрасываются.
+func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
lp := layout.Plan{
- Type: layout.MediaType(plan.Type),
- Title: plan.Title,
- Year: plan.Year,
+ Type: layout.MediaType(plan.Type),
+ Title: plan.Title,
+ Year: plan.Year,
+ ProviderTag: providerTag,
}
for _, f := range plan.Files {
role, ok := mapRole(f.Role)
diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go
index e842745..59f9974 100644
--- a/internal/worker/review_test.go
+++ b/internal/worker/review_test.go
@@ -528,6 +528,80 @@ func TestApplyOverrides(t *testing.T) {
}
}
+func TestRecognizeOne_AutoApplies(t *testing.T) {
+ root := t.TempDir()
+ downloads := filepath.Join(root, "downloads")
+ movies := filepath.Join(root, "movies")
+ series := filepath.Join(root, "series")
+ for _, d := range []string{downloads, movies, series} {
+ _ = os.MkdirAll(d, 0o755)
+ }
+ plan := seriesResult().Plan
+ plan.Confidence = 0.95
+ for _, f := range plan.Files {
+ p := filepath.Join(downloads, f.Src)
+ _ = os.MkdirAll(filepath.Dir(p), 0o755)
+ _ = os.WriteFile(p, []byte("x"), 0o644)
+ }
+ lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series})
+
+ st := newMemStore()
+ st.put(completedDownload(1))
+ qb := &fakeQbt{
+ torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}},
+ files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}},
+ }
+ rec := &fakeRecognizer{result: recognize.Result{
+ Plan: plan,
+ Decision: recognize.Decision{Auto: true},
+ Match: &recognize.Match{Provider: "tmdb", ProviderID: "42", Title: "Show", Year: 2006},
+ }}
+ w := testWorkerWith(st, qb, rec, lay)
+
+ w.recognizeOne(context.Background(), 1)
+
+ if st.downloads[1].State != store.StateDone {
+ t.Fatalf("state = %q, want done (auto)", st.downloads[1].State)
+ }
+ // Provider-тег попал в имя папки.
+ want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv")
+ if _, err := os.Stat(want); err != nil {
+ t.Errorf("expected auto-linked file %q: %v", want, err)
+ }
+ if len(st.links) != 2 {
+ t.Errorf("file_links = %d, want 2", len(st.links))
+ }
+}
+
+func TestApply_UsesProviderTag(t *testing.T) {
+ f := newApplyFixture(t, seriesResult().Plan)
+ f.st.recs[0].Provider = store.NullString("tmdb")
+ f.st.recs[0].ProviderID = store.NullString("603")
+
+ if err := f.w.Apply(context.Background(), 1); err != nil {
+ t.Fatalf("Apply: %v", err)
+ }
+ want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv")
+ if _, err := os.Stat(want); err != nil {
+ t.Errorf("expected tagged path %q: %v", want, err)
+ }
+}
+
+func TestProviderTag(t *testing.T) {
+ cases := []struct{ provider, id, want string }{
+ {"tmdb", "603", "tmdbid-603"},
+ {"tvdb", "123", "tvdbid-123"},
+ {"none", "", ""},
+ {"tmdb", "", ""},
+ {"weird", "1", ""},
+ }
+ for _, c := range cases {
+ if got := providerTag(c.provider, c.id); got != c.want {
+ t.Errorf("providerTag(%q,%q) = %q, want %q", c.provider, c.id, got, c.want)
+ }
+ }
+}
+
func TestToLayoutPlan(t *testing.T) {
s, e := 1, 3
plan := recognize.Plan{
@@ -537,7 +611,7 @@ func TestToLayoutPlan(t *testing.T) {
{Src: "sample.mkv", Role: "sample"},
},
}
- lp := toLayoutPlan(plan, "/d")
+ lp := toLayoutPlan(plan, "/d", "tmdbid-1")
if len(lp.Files) != 1 {
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
}
@@ -547,4 +621,7 @@ func TestToLayoutPlan(t *testing.T) {
if lp.Files[0].Role != layout.RoleEpisode {
t.Errorf("role = %q", lp.Files[0].Role)
}
+ if lp.ProviderTag != "tmdbid-1" {
+ t.Errorf("provider tag = %q", lp.ProviderTag)
+ }
}
diff --git a/web/templates/review.html b/web/templates/review.html
index eac62b7..8eb8de4 100644
--- a/web/templates/review.html
+++ b/web/templates/review.html
@@ -57,6 +57,7 @@
Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}
· Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}}
{{if .Year}}· Год: {{.Year}}{{end}}
+ {{if .Provider}}· База: {{.Provider}} {{.ProviderID}}{{end}}